From ab0c116c9e38b33dc483419c62e74ca8ccab23cd Mon Sep 17 00:00:00 2001 From: Variet Worker Date: Wed, 18 Mar 2026 08:34:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(ext):=20!stop=20getActiveSessionId=20stale?= =?UTF-8?q?=20primitive=20=E2=80=94=20use=20step-probe=20getter=20#task-41?= =?UTF-8?q?0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extension/package.json | 4 +- extension/src/extension.ts | 6 +- extension/src/step-probe.ts | 136 ++++++++++++++++++++---------------- 3 files changed, 79 insertions(+), 67 deletions(-) diff --git a/extension/package.json b/extension/package.json index a995d2b..f4796a3 100644 --- a/extension/package.json +++ b/extension/package.json @@ -2,7 +2,7 @@ "name": "gravity-bridge", "displayName": "Gravity Bridge", "description": "Antigravity ↔ Discord 브리지 연동 확장", - "version": "0.4.5", + "version": "0.4.6", "publisher": "variet", "engines": { "vscode": "^1.100.0" @@ -85,4 +85,4 @@ "dependencies": { "ws": "^8.19.0" } -} +} \ No newline at end of file diff --git a/extension/src/extension.ts b/extension/src/extension.ts index b33c407..953b649 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -16,7 +16,7 @@ import * as path from 'path'; import * as os from 'os'; import * as cp from 'child_process'; import { WSBridgeClient, WSResponseData, WSCommandData } from './ws-client'; -import { initStepProbe, BridgeContext, writePendingApproval, tryApprovalStrategies, writeRegistration, getApprovalContext, resetPendingState, handleDiffReviewResponse } from './step-probe'; +import { initStepProbe, BridgeContext, writePendingApproval, tryApprovalStrategies, writeRegistration, getApprovalContext, resetPendingState, handleDiffReviewResponse, getActiveSessionId as getStepProbeSessionId } from './step-probe'; import { startHttpBridge, getDeterministicPort, HttpBridgeContext } from './http-bridge'; import { setupApprovalObserver } from './html-patcher'; import { watchCommandsDir, handleWSCommand, disposeCommandsWatcher, CommandHandlerContext } from './command-handler'; @@ -430,7 +430,7 @@ export async function activate(context: vscode.ExtensionContext) { bridgePath, projectName, sdk, ls: sdk?.ls, autoApproveEnabled, logToFile, onAutoApproveChanged: (enabled: boolean) => { autoApproveEnabled = enabled; }, recentDiscordSentTexts, - getActiveSessionId: () => activeSessionId, + getActiveSessionId: () => getStepProbeSessionId(), }, data); }, onInstanceUpdate: (count, instances) => { @@ -560,7 +560,7 @@ export async function activate(context: vscode.ExtensionContext) { bridgePath, projectName, sdk, ls: sdk?.ls, autoApproveEnabled, logToFile, onAutoApproveChanged: (enabled: boolean) => { autoApproveEnabled = enabled; }, recentDiscordSentTexts, - getActiveSessionId: () => activeSessionId, + getActiveSessionId: () => getStepProbeSessionId(), }); // Response watcher is now initialized by initStepProbe() above diff --git a/extension/src/step-probe.ts b/extension/src/step-probe.ts index 2c5193a..e783397 100644 --- a/extension/src/step-probe.ts +++ b/extension/src/step-probe.ts @@ -26,7 +26,7 @@ export interface BridgeContext { diffReviewMetadata: Map; recentDiscordSentTexts: Map; writeChatSnapshot: (text: string) => void; - writeChatSnapshotWithFiles: (text: string, files: Array<{name: string, content: string}>) => void; + writeChatSnapshotWithFiles: (text: string, files: Array<{ name: string, content: string }>) => void; } let ctx: BridgeContext; @@ -52,6 +52,18 @@ export function getApprovalContext(): { sessionId: string; stepIndex: number } { }; } +/** + * Get the active session ID tracked by step-probe polling. + * Used by command-handler (via extension.ts) for !stop → CancelCascadeInvocation. + * + * CRITICAL: extension.ts must use this getter instead of its own module-level + * `activeSessionId` variable, which is a stale primitive copy that never updates + * when step-probe discovers new sessions. + */ +export function getActiveSessionId(): string { + return ctx?.activeSessionId || ''; +} + /** * Reset pending state after successful approval. * Called after WS response triggers approval in extension.ts. @@ -220,9 +232,9 @@ function setupMonitor() { let lastRelayedTaskText = ''; // dedup TASK_BOUNDARY relay let wasRunning = false; // track RUNNING→IDLE transition for response capture let lastUserInputStepIdx = -1; // track user input for response matching - let pendingModifiedFiles: string[] = []; // accumulate modified files during RUNNING - let pendingModifiedFilePaths: string[] = []; // full paths for diff review - let pendingEditStepIndices: number[] = []; // step indices for AcknowledgeCascadeCodeEdit + let pendingModifiedFiles: string[] = []; // accumulate modified files during RUNNING + let pendingModifiedFilePaths: string[] = []; // full paths for diff review + let pendingEditStepIndices: number[] = []; // step indices for AcknowledgeCascadeCodeEdit let lastResponseCaptureStep = -1; // dedup: don't capture same response twice setInterval(async () => { @@ -343,7 +355,7 @@ function setupMonitor() { if (delta > 0) { console.log(`Gravity Bridge: [POLL#${pollCount}] +${delta} steps (${currentCount}) "${currentTitle}"`); - + // Real-time response capture: fetch latest steps on every delta>0 if (isRunning && currentCount > lastResponseCaptureStep && ctx.sdk) { try { @@ -360,30 +372,30 @@ function setupMonitor() { const actualIdx = rtOffset + ri; if (actualIdx <= lastResponseCaptureStep) continue; // Track file write steps for diff review - if (s?.metadata?.toolCall?.argumentsJson) { - try { - const tcArgs = JSON.parse(s.metadata.toolCall.argumentsJson); - const tf = tcArgs.TargetFile || tcArgs.target_file || ''; - if (tf) { - const bn = tf.split(/[\\/]/).pop() || tf; - if (!pendingModifiedFiles.includes(bn)) { - pendingModifiedFiles.push(bn); - pendingModifiedFilePaths.push(tf); - pendingEditStepIndices.push(actualIdx); - ctx.logToFile(`[DIFF-TRACK] + ${bn} (step ${actualIdx})`); + if (s?.metadata?.toolCall?.argumentsJson) { + try { + const tcArgs = JSON.parse(s.metadata.toolCall.argumentsJson); + const tf = tcArgs.TargetFile || tcArgs.target_file || ''; + if (tf) { + const bn = tf.split(/[\\/]/).pop() || tf; + if (!pendingModifiedFiles.includes(bn)) { + pendingModifiedFiles.push(bn); + pendingModifiedFilePaths.push(tf); + pendingEditStepIndices.push(actualIdx); + ctx.logToFile(`[DIFF-TRACK] + ${bn} (step ${actualIdx})`); + } } - } - } catch { } - } - if (sType.includes('PLANNER_RESPONSE') && s?.status?.includes('DONE')) { + } catch { } + } + if (sType.includes('PLANNER_RESPONSE') && s?.status?.includes('DONE')) { const pr = s?.plannerResponse; if (pr) { let text = pr.modifiedResponse || pr.rawText || pr.text || ''; if (text.length > 10) { lastResponseCaptureStep = actualIdx; ctx.logToFile(`[RT-CAPTURE] step=${actualIdx} (${text.length} chars)`); - const truncated = text.length > 3500 - ? text.substring(0, 3500) + '\n\n_(이하 생략)_' + const truncated = text.length > 3500 + ? text.substring(0, 3500) + '\n\n_(이하 생략)_' : text; ctx.writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`); break; @@ -425,37 +437,37 @@ function setupMonitor() { // Steps progressed — if we had a pending approval, it was handled in AG directly if (!ctx.sawRunningAfterPending && ctx.lastPendingStepIndex >= 0) { // Mark pending as auto_resolved so bot can update Discord message - let resolvedCount = 0; - let primaryCommand = ''; - const pendingFiles = fs.readdirSync(path.join(ctx.bridgePath, 'pending')).filter((f: string) => f.endsWith('.json')); - const nowMs = Date.now(); - for (const pf of pendingFiles) { - const pfPath = path.join(ctx.bridgePath, 'pending', pf); - try { - const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8')); - if (pd.status !== 'pending') continue; - if (pd.project_name && pd.project_name !== ctx.projectName) continue; - // Limit to same session AND (same step or recent) - const ageMs = nowMs - (pd.timestamp * 1000); - const isMatch = (pd.conversation_id === ctx.activeSessionId) && - (pd.step_index === ctx.lastPendingStepIndex || (ageMs < 60_000 && ageMs >= 0)); - if (isMatch) { - pd.status = 'auto_resolved'; - fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8'); - resolvedCount++; - const cmd = pd.command || ''; - if (cmd.length > primaryCommand.length && cmd !== 'Deny' && !cmd.includes('Allow')) { - primaryCommand = cmd; - } else if (!primaryCommand) { - primaryCommand = cmd; - } + let resolvedCount = 0; + let primaryCommand = ''; + const pendingFiles = fs.readdirSync(path.join(ctx.bridgePath, 'pending')).filter((f: string) => f.endsWith('.json')); + const nowMs = Date.now(); + for (const pf of pendingFiles) { + const pfPath = path.join(ctx.bridgePath, 'pending', pf); + try { + const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8')); + if (pd.status !== 'pending') continue; + if (pd.project_name && pd.project_name !== ctx.projectName) continue; + // Limit to same session AND (same step or recent) + const ageMs = nowMs - (pd.timestamp * 1000); + const isMatch = (pd.conversation_id === ctx.activeSessionId) && + (pd.step_index === ctx.lastPendingStepIndex || (ageMs < 60_000 && ageMs >= 0)); + if (isMatch) { + pd.status = 'auto_resolved'; + fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8'); + resolvedCount++; + const cmd = pd.command || ''; + if (cmd.length > primaryCommand.length && cmd !== 'Deny' && !cmd.includes('Allow')) { + primaryCommand = cmd; + } else if (!primaryCommand) { + primaryCommand = cmd; } - } catch (e: any) { ctx.logToFile(`[AUTO-RESOLVE] parse error for ${pf}: ${e.message}`); } - } - if (resolvedCount > 0) { - ctx.logToFile(`[AUTO-RESOLVE] step=${ctx.lastPendingStepIndex} progressed → marked ${resolvedCount} pending(s)`); - ctx.writeChatSnapshot(`✅ **AG에서 직접 진행됨** (step ${ctx.lastPendingStepIndex})\n\n\`${primaryCommand.substring(0, 200)}\``); - } + } + } catch (e: any) { ctx.logToFile(`[AUTO-RESOLVE] parse error for ${pf}: ${e.message}`); } + } + if (resolvedCount > 0) { + ctx.logToFile(`[AUTO-RESOLVE] step=${ctx.lastPendingStepIndex} progressed → marked ${resolvedCount} pending(s)`); + ctx.writeChatSnapshot(`✅ **AG에서 직접 진행됨** (step ${ctx.lastPendingStepIndex})\n\n\`${primaryCommand.substring(0, 200)}\``); + } ctx.lastPendingStepIndex = -1; // Clear memory dedup for this session (step progressed, new WAITING steps are allowed) for (const k of recentPendingSteps.keys()) { @@ -548,7 +560,7 @@ function setupMonitor() { description: `Step #${actualIndex} (${(oStep.type || '').replace('CORTEX_STEP_TYPE_', '')})`, step_type: ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search'].includes(toolName) ? 'file_permission' : ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName) ? 'code_edit' - : toolName, + : toolName, step_index: actualIndex, source: 'step_probe_offset', }); @@ -614,7 +626,7 @@ function setupMonitor() { description, step_type: ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search'].includes(toolName) ? 'file_permission' : ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName) ? 'code_edit' - : toolName, + : toolName, step_index: si, source: 'step_probe', }); @@ -731,7 +743,7 @@ function setupMonitor() { : fileContent; // Write as snapshot with attached_files for bot to send as Discord file ctx.writeChatSnapshotWithFiles( - `📎 **문서: ${fileName}** (${Math.round(fileContent.length/1024)}KB)`, + `📎 **문서: ${fileName}** (${Math.round(fileContent.length / 1024)}KB)`, [{ name: fileName, content: truncatedContent }] ); ctx.logToFile(`[NOTIFY-STEP] relayed artifact: ${fileName} (${fileContent.length} chars)`); @@ -797,14 +809,14 @@ function setupMonitor() { const sentAt = ctx.recentDiscordSentTexts.get(trimmed); if (sentAt && (Date.now() - sentAt) < 60_000) { ctx.recentDiscordSentTexts.delete(trimmed); - ctx.logToFile(`[USER-MSG] skipped echo relay (Discord origin, ${Math.round((Date.now()-sentAt)/1000)}s ago)`); + ctx.logToFile(`[USER-MSG] skipped echo relay (Discord origin, ${Math.round((Date.now() - sentAt) / 1000)}s ago)`); } else if (umText.length > 2) { // Content-based dedup: AG can create multiple USER_INPUT steps for the same message // (e.g. comment-while-working feature). Skip if same text relayed within 30s. const dedupKey = `user_msg:${trimmed}`; const lastRelayed = lastSnapshotText.get(dedupKey); if (lastRelayed && (Date.now() - Number(lastRelayed)) < 30_000) { - ctx.logToFile(`[USER-MSG] skipped duplicate relay (same text ${Math.round((Date.now() - Number(lastRelayed))/1000)}s ago)`); + ctx.logToFile(`[USER-MSG] skipped duplicate relay (same text ${Math.round((Date.now() - Number(lastRelayed)) / 1000)}s ago)`); } else { lastSnapshotText.set(dedupKey, String(Date.now())); const truncated = umText.length > 800 @@ -858,7 +870,7 @@ function setupMonitor() { } // Log first time to capture actual field names if (!textContent) { - ctx.logToFile(`[RESPONSE-CAPTURE] plannerResponse keys: [${Object.keys(pr).join(',')}] values(100): ${JSON.stringify(pr).substring(0,200)}`); + ctx.logToFile(`[RESPONSE-CAPTURE] plannerResponse keys: [${Object.keys(pr).join(',')}] values(100): ${JSON.stringify(pr).substring(0, 200)}`); } } // Extract from ephemeralMessage field @@ -879,8 +891,8 @@ function setupMonitor() { if (!textContent && s?.rawOutput) textContent = typeof s.rawOutput === 'string' ? s.rawOutput : JSON.stringify(s.rawOutput); if (textContent.length > 10) { ctx.logToFile(`[RESPONSE-CAPTURE] found ${sType} (${textContent.length} chars) at step ${offset + ri}`); - const truncated = textContent.length > 3500 - ? textContent.substring(0, 3500) + '\n\n_(이하 생략)_' + const truncated = textContent.length > 3500 + ? textContent.substring(0, 3500) + '\n\n_(이하 생략)_' : textContent; ctx.writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`); break; @@ -1252,7 +1264,7 @@ async function processResponseFile(filePath: string) { /** Write a pending approval file matching Bot's ApprovalRequest dataclass. */ -export function writePendingApproval(data: { conversation_id: string; command: string; description: string; step_type?: string; step_index?: number; source?: string; buttons?: Array<{text: string; index: number}>; modified_files?: string[]; edit_step_indices?: number[] }) { +export function writePendingApproval(data: { conversation_id: string; command: string; description: string; step_type?: string; step_index?: number; source?: string; buttons?: Array<{ text: string; index: number }>; modified_files?: string[]; edit_step_indices?: number[] }) { try { const pendingDir = path.join(ctx.bridgePath, 'pending'); if (!fs.existsSync(pendingDir)) { fs.mkdirSync(pendingDir, { recursive: true }); } @@ -1459,7 +1471,7 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string } // Direct LS RPC with correct method name try { - ctx.logToFile(`[APPROVAL-CODE-EDIT] acknowledgeCodeActionStep(cascadeId=${sessionId.substring(0,8)}, accept=${approved}, stepIndices=[${effectiveStepIndex}])`); + ctx.logToFile(`[APPROVAL-CODE-EDIT] acknowledgeCodeActionStep(cascadeId=${sessionId.substring(0, 8)}, accept=${approved}, stepIndices=[${effectiveStepIndex}])`); const ackResult = await ctx.sdk.ls.rawRPC('acknowledgeCodeActionStep', { cascadeId: sessionId, accept: approved,