fix(bridge): response file race condition + Run button regex + known issues

- Fix: processResponseFile no longer deletes response files for DOM observer
  approvals, allowing renderer pollResponse to find and serve them via HTTP
- Fix: Run button regex ^Run$ → ^Run to match 'Run Alt+⏎' button text
- Fix: BTN-DUMP diagnostic added to generateApprovalObserverScript (source)
- Doc: 2 new known issues (race condition, renderer script 3-location confusion)
- Doc: devlog entry #19
This commit is contained in:
2026-03-08 22:58:17 +09:00
parent 32726d4d3a
commit 027135e2b5
5 changed files with 330 additions and 223 deletions

View File

@@ -418,6 +418,7 @@ function startObserverHttpBridge(): Promise<number> {
const pending = {
...data,
request_id: rid,
conversation_id: activeSessionId || '',
timestamp: Date.now() / 1000,
status: 'pending',
project_name: projectName,
@@ -575,7 +576,7 @@ function generateApprovalObserverScript(_port: number): string {
// ── Button patterns to detect (order matters: first match wins per scan) ──
var PATS=[
{re:/^Run$/i, type:'terminal_command'},
{re:/^Run/i, type:'terminal_command'},
{re:/^Accept all$/i, type:'diff_review'},
{re:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'},
@@ -659,6 +660,23 @@ function generateApprovalObserverScript(_port: number): string {
if(document.body)searchRoots.push(document.body);
if(!searchRoots.length)return;
// ── DIAGNOSTIC: dump ALL button-like elements EVERY scan cycle ──
{
var dumpBtns=[];
var totalChecked=0;
for(var dr=0;dr<searchRoots.length;dr++){
var dbs=searchRoots[dr].querySelectorAll('button,[role="button"]');
totalChecked+=dbs.length;
for(var di=0;di<dbs.length;di++){
var db=dbs[di];
var dt=(db.textContent||'').trim();
var dtShort=dt.replace(/\\s+/g,' ').substring(0,50);
dumpBtns.push(db.tagName+'['+dt.length+']:'+dtShort);
}
}
log('[BTN-DUMP] roots='+searchRoots.length+' total='+totalChecked+' btns='+dumpBtns.length+': '+JSON.stringify(dumpBtns.slice(0,15)));
}
var seen={}; // dedupe buttons across search roots
for(var r=0;r<searchRoots.length;r++){
var allBtns=searchRoots[r].querySelectorAll('button');
@@ -877,18 +895,17 @@ function setupMonitor() {
// stepIndex on each → perfect for dedup
// ══════════════════════════════════════════════════════════════════════
let pollCount = 0;
// activeSessionId is module-level (for writeChatSnapshot lazy registration)
// activeSessionId is module-level (used by writeChatSnapshot for lazy registration)
let activeSessionTitle = '';
let lastKnownStepCount = 0;
let lastNotifyStepIndex = -1;
let lastTaskStepIndex = -1;
let lastToolStepIndex = -1; // track latestToolCallStep for instant detection
let toolStepDumped = false; // dump structure once for debugging
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
let lastPendingTime = 0; // cooldown: minimum gap between pendings
let sawRunningAfterPending = true; // gate: must see delta>0 before next pending
let lastModTime = ''; // track lastModifiedTime to distinguish thinking vs approval
let stallProbed = false; // prevent repeated step probes during same stall
setInterval(async () => {
pollCount++;
@@ -943,8 +960,16 @@ function setupMonitor() {
lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1;
lastTaskStepIndex = bestSession.latestTaskBoundaryStep?.stepIndex ?? -1;
lastPendingStepIndex = -1;
stallProbed = false;
// Don't register here — registration happens lazily in writeChatSnapshot/writePendingApproval
// to avoid race conditions between multiple extension instances
// Dump session keys + trajectoryMetadata on session change
const allKeys = Object.keys(bestSession);
logToFile(`[SESSION-INIT] id=${activeSessionId.substring(0, 8)} keys=[${allKeys.join(',')}]`);
const trajMeta = bestSession.trajectoryMetadata;
if (trajMeta) {
logToFile(`[SESSION-INIT] trajectoryMetadata=${JSON.stringify(trajMeta).substring(0, 500)}`);
}
console.log(`Gravity Bridge: [POLL#${pollCount}] session: ${activeSessionId.substring(0, 8)} "${currentTitle}" steps=${currentCount} ${isRunning ? 'RUNNING' : 'idle'}`);
return;
}
@@ -962,63 +987,12 @@ function setupMonitor() {
logToFile(`[POLL#${pollCount}] status=${statusStr} steps=${currentCount} delta=${delta}`);
}
// ── PRIMARY: Instant tool-call-based approval detection ──
// latestToolCallStep contains the most recent tool call with its status.
// If status includes WAITING, the AI is waiting for user approval.
const toolStep = bestSession.latestToolCallStep;
if (toolStep && toolStep.stepIndex > lastToolStepIndex) {
const stepData = toolStep.step || {};
// Dump structure once for debugging (keys + first 800 chars)
if (!toolStepDumped) {
logToFile(`[TOOL-STEP] keys: ${JSON.stringify(Object.keys(toolStep))}`);
logToFile(`[TOOL-STEP] step keys: ${JSON.stringify(Object.keys(stepData))}`);
logToFile(`[TOOL-STEP] full: ${JSON.stringify(toolStep).substring(0, 800)}`);
toolStepDumped = true;
}
// ── PRIMARY: Step-probe-based approval detection ──
// latestToolCallStep does NOT exist in GetAllCascadeTrajectories response.
// Instead, on early stall (idle=2, ~10s), probe GetCascadeTrajectorySteps
// to fetch the latest step and check if it's a tool call awaiting approval.
// Check for WAITING status in various possible field locations
const stepStatus = String(
stepData.status || toolStep.status || stepData.stepStatus || ''
).toLowerCase();
const isWaiting = stepStatus.includes('waiting') || stepStatus.includes('pending');
if (isWaiting && toolStep.stepIndex !== lastPendingStepIndex) {
// INSTANT detection! Extract tool call details
const command = extractToolCommand(stepData);
const description = extractToolDescription(stepData, currentTitle, toolStep.stepIndex);
logToFile(`[TOOL-DETECT] INSTANT! step=${toolStep.stepIndex} status='${stepStatus}' cmd='${command}'`);
lastPendingStepIndex = toolStep.stepIndex;
lastPendingTime = Date.now();
sawRunningAfterPending = false;
writePendingApproval({
conversation_id: activeSessionId,
command,
description,
step_type: 'tool_call',
step_index: toolStep.stepIndex,
});
} else if (!isWaiting) {
// Tool call exists but not waiting — log for diagnostics
if (toolStep.stepIndex > lastToolStepIndex) {
logToFile(`[TOOL-STEP] step=${toolStep.stepIndex} status='${stepStatus}' (not waiting)`);
}
}
lastToolStepIndex = toolStep.stepIndex;
}
// ── FALLBACK: Stall-based approval detection ──
// Kept as safety net for edge cases where latestToolCallStep doesn't report WAITING.
// Threshold raised from 6 (30s) to 20 (100s) since primary detection above handles most cases.
// DEBUG: dump session keys on first poll
if (pollCount === 1) {
const keys = Object.keys(bestSession).filter(k => !['latestNotifyUserStep', 'latestTaskBoundaryStep', 'latestToolCallStep'].includes(k));
logToFile(`[DEBUG] session keys: ${keys.join(', ')}`);
logToFile(`[DEBUG] lastModifiedTime=${bestSession.lastModifiedTime}`);
if (toolStep) logToFile(`[DEBUG] toolStep present, stepIndex=${toolStep.stepIndex}`);
}
// ── STALL-BASED approval detection with step probe ──
const currentModTime = bestSession.lastModifiedTime || (bestSession as any).lastModifiedTimestamp || (bestSession as any).modifiedTime || '';
const modTimeChanged = currentModTime !== lastModTime;
@@ -1032,11 +1006,13 @@ function setupMonitor() {
if (delta > 0) {
consecutiveIdleCount = 0;
sawRunningAfterPending = true;
stallProbed = false; // allow re-probe on next stall
lastModTime = currentModTime;
} else if (isStall) {
if (modTimeChanged) {
// lastModifiedTime is still changing = AI is thinking, NOT approval
consecutiveIdleCount = 0; // Reset!
stallProbed = false;
if (pollCount <= 10 || pollCount % 12 === 0) {
logToFile(`[THINK] step=${currentCount} modTime changing → not stall`);
}
@@ -1046,11 +1022,82 @@ function setupMonitor() {
}
lastModTime = currentModTime;
// ── Step probe: on stall, fetch latest step via cascadeId (retry until WAITING found) ──
// CONFIRMED: param='cascadeId', id=sessionId (map key from trajectorySummaries)
// Retries every 2 polls (~10s) because RUN_COMMAND step may not be created yet
if (consecutiveIdleCount >= 1 && consecutiveIdleCount % 2 === 1 && !stallProbed) {
try {
const stepsResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: bestSessionId,
});
if (stepsResp?.steps?.length > 0) {
const steps = stepsResp.steps;
// Diagnostic: compare returned steps vs trajectory stepCount
logToFile(`[STEP-PROBE] returned=${steps.length} vs trajectory.stepCount=${currentCount}`);
// Scan last 5 steps backwards to find WAITING (RUN_COMMAND may not be last)
let foundWaiting = false;
for (let si = steps.length - 1; si >= Math.max(0, steps.length - 5); si--) {
const step = steps[si];
const stepStatus = step?.status || '';
const stepType = step?.type || '';
if (stepStatus === 'CORTEX_STEP_STATUS_WAITING') {
foundWaiting = true;
// Extract command from metadata.toolCall or direct fields
const toolCall = step?.metadata?.toolCall;
const toolName = toolCall?.name || stepType.replace('CORTEX_STEP_TYPE_', '').toLowerCase();
let command = toolName;
// Parse argumentsJson for command details
if (toolCall?.argumentsJson) {
try {
const args = JSON.parse(toolCall.argumentsJson);
if (args.CommandLine) {
command = `${toolName}: ${args.CommandLine.substring(0, 150)}`;
} else if (args.TargetFile) {
command = `${toolName}: ${args.TargetFile.split(/[\\/]/).pop()}`;
} else {
command = `${toolName}: ${Object.keys(args).join(', ')}`;
}
} catch { command = toolName; }
}
const description = `Step #${si} (${stepType.replace('CORTEX_STEP_TYPE_', '')})`;
logToFile(`[STEP-PROBE] ★ WAITING! step=${si} type=${stepType} cmd='${command}'`);
if (si !== lastPendingStepIndex) {
stallProbed = true; // found WAITING — stop retrying
lastPendingStepIndex = si;
lastPendingTime = Date.now();
sawRunningAfterPending = false;
writePendingApproval({
conversation_id: activeSessionId,
command,
description,
step_type: toolName,
step_index: si,
source: 'step_probe',
});
}
break;
}
}
if (!foundWaiting) {
const lastStep = steps[steps.length - 1];
logToFile(`[STEP-PROBE] lastStep status=${lastStep?.status} type=${lastStep?.type} (not waiting)`);
}
}
} catch (e: any) {
logToFile(`[STEP-PROBE] error: ${e.message}`);
}
}
const now = Date.now();
const cooldownOk = (now - lastPendingTime) > 60_000;
if (consecutiveIdleCount >= 20 && sawRunningAfterPending && cooldownOk) {
// 20 polls × 5s = 100 seconds — fallback only
if (consecutiveIdleCount >= 8 && sawRunningAfterPending && cooldownOk) {
// 8 polls × 5s = 40 seconds — fallback (reduced from 100s)
lastPendingStepIndex = currentCount;
lastPendingTime = now;
sawRunningAfterPending = false;
@@ -1059,8 +1106,8 @@ function setupMonitor() {
const description = `승인 대기 감지 — fallback (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
logToFile(`[STALL-FALLBACK] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
writePendingApproval({ conversation_id: activeSessionId, command, description });
} else if (consecutiveIdleCount === 20) {
writePendingApproval({ conversation_id: activeSessionId, command, description, source: 'stall_fallback' });
} else if (consecutiveIdleCount === 8) {
const reasons = [];
if (!sawRunningAfterPending) reasons.push('needDelta>0');
if (!cooldownOk) reasons.push(`cooldown(${Math.round((60000 - (now - lastPendingTime)) / 1000)}s)`);
@@ -1155,7 +1202,8 @@ async function processResponseFile(filePath: string) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
sessionId = pending.conversation_id || '';
isDomObserver = pending.auto_detected === true && pending.source === 'dom_observer';
isDomObserver = pending.auto_detected === true
|| pending.source === 'dom_observer';
} catch { }
}
@@ -1166,59 +1214,53 @@ async function processResponseFile(filePath: string) {
const approved = resp.approved;
if (isDomObserver) {
// DOM observer path: renderer polls /response/:rid and clicks the button directly
logToFile(`[RESPONSE] DOM observer approval — renderer will handle click (rid=${resp.request_id})`);
// DOM observer path: renderer polls /response/:rid and clicks directly
logToFile(`[RESPONSE] renderer-handled approval (rid=${resp.request_id})`);
} else {
// Stall-detection path: use VS Code commands (legacy, focus-dependent)
logToFile(`[RESPONSE] Stall-detection approval — using VS Code commands`);
// Step probe / stall path: relay approval to DOM observer pending files
// The renderer polls /response/:rid and can click the actual button
logToFile(`[RESPONSE] step_probe/stall → relaying to DOM observer pending files`);
// Focus panel
for (let i = 0; i < 2; i++) {
try {
await vscode.commands.executeCommand('antigravity.agentPanel.focus');
if (i === 0) logToFile('[RESPONSE] panel focus attempt 1');
} catch (e: any) {
logToFile(`[RESPONSE] panel focus attempt ${i + 1} failed: ${e.message}`);
}
await new Promise(r => setTimeout(r, 500));
}
const pendingDir = path.join(bridgePath, 'pending');
const responseDir = path.join(bridgePath, 'response');
if (!fs.existsSync(responseDir)) fs.mkdirSync(responseDir, { recursive: true });
if (approved) {
const approveCommands = [
'antigravity.terminalCommand.run',
'antigravity.terminalCommand.accept',
'antigravity.command.accept',
'antigravity.agent.acceptAgentStep',
];
for (const cmd of approveCommands) {
let relayCount = 0;
try {
const files = fs.readdirSync(pendingDir).filter((f: string) => f.endsWith('.json'));
for (const f of files) {
try {
await vscode.commands.executeCommand(cmd);
logToFile(`[RESPONSE] cmd OK: ${cmd}`);
} catch (e: any) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd}${e.message}`);
}
}
} else {
const rejectCommands = [
'antigravity.terminalCommand.reject',
'antigravity.command.reject',
'antigravity.agent.rejectAgentStep',
];
for (const cmd of rejectCommands) {
try {
await vscode.commands.executeCommand(cmd);
logToFile(`[RESPONSE] cmd OK: ${cmd}`);
} catch (e: any) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd}${e.message}`);
}
const pd = JSON.parse(fs.readFileSync(path.join(pendingDir, f), 'utf-8'));
if (pd.source === 'dom_observer' && pd.status === 'pending') {
// Write response file for this DOM observer pending
const responsePayload = {
request_id: pd.request_id,
approved,
timestamp: Date.now() / 1000,
};
fs.writeFileSync(
path.join(responseDir, `${pd.request_id}.json`),
JSON.stringify(responsePayload, null, 2), 'utf-8'
);
relayCount++;
logToFile(`[RESPONSE] relayed to DOM pending: ${pd.request_id} approved=${approved}`);
}
} catch { }
}
} catch (e: any) {
logToFile(`[RESPONSE] relay scan error: ${e.message}`);
}
logToFile(`[RESPONSE] relayed to ${relayCount} DOM observer pending files`);
}
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'stall'})`);
// Cleanup response file (but NOT pending — renderer still polls it)
try { fs.unlinkSync(filePath); } catch { }
// Cleanup response file — BUT NOT for DOM observer!
// DOM observer: renderer's pollResponse needs to find it via HTTP GET /response/:rid
// Non-DOM (stall/step_probe relay): watcher already handled it, safe to delete
if (!isDomObserver) {
try { fs.unlinkSync(filePath); } catch { }
}
} catch (e: any) {
const log = `[RESPONSE] error: ${e.message}`;
console.log(`Gravity Bridge: ${log}`);
@@ -1340,7 +1382,7 @@ function extractToolDescription(stepData: any, sessionTitle: string, stepIndex:
}
/** Write a pending approval file matching Bot's ApprovalRequest dataclass. */
function writePendingApproval(data: { conversation_id: string; command: string; description: string; step_type?: string; step_index?: number }) {
function writePendingApproval(data: { conversation_id: string; command: string; description: string; step_type?: string; step_index?: number; source?: string }) {
try {
const pendingDir = path.join(bridgePath, 'pending');
if (!fs.existsSync(pendingDir)) { fs.mkdirSync(pendingDir, { recursive: true }); }
@@ -1356,6 +1398,7 @@ function writePendingApproval(data: { conversation_id: string; command: string;
project_name: projectName,
...(data.step_type ? { step_type: data.step_type } : {}),
...(data.step_index !== undefined ? { step_index: data.step_index } : {}),
...(data.source ? { source: data.source } : {}),
};
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
console.log(`Gravity Bridge: pending approval written → ${id}.json`);