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

@@ -447,6 +447,7 @@ function startObserverHttpBridge() {
const pending = {
...data,
request_id: rid,
conversation_id: activeSessionId || '',
timestamp: Date.now() / 1000,
status: 'pending',
project_name: projectName,
@@ -604,7 +605,7 @@ function generateApprovalObserverScript(_port) {
// ── 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'},
@@ -688,6 +689,23 @@ function generateApprovalObserverScript(_port) {
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');
@@ -907,18 +925,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++;
if (pollCount <= 3 || pollCount % 12 === 0) {
@@ -971,8 +988,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;
}
@@ -986,57 +1011,11 @@ function setupMonitor() {
if (pollCount <= 10 || pollCount % 6 === 0 || delta > 0) {
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;
}
// 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}`);
}
// ── 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.
// ── STALL-BASED approval detection with step probe ──
const currentModTime = bestSession.lastModifiedTime || bestSession.lastModifiedTimestamp || bestSession.modifiedTime || '';
const modTimeChanged = currentModTime !== lastModTime;
const isStall = isRunning && delta === 0;
@@ -1047,12 +1026,14 @@ 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`);
}
@@ -1062,19 +1043,90 @@ function setupMonitor() {
consecutiveIdleCount++;
}
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) {
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;
const command = `Stall at step ${currentCount} (fallback)`;
const description = `승인 대기 감지 — fallback (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
logToFile(`[STALL-FALLBACK] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
writePendingApproval({ conversation_id: activeSessionId, command, description });
writePendingApproval({ conversation_id: activeSessionId, command, description, source: 'stall_fallback' });
}
else if (consecutiveIdleCount === 20) {
else if (consecutiveIdleCount === 8) {
const reasons = [];
if (!sawRunningAfterPending)
reasons.push('needDelta>0');
@@ -1168,7 +1220,8 @@ async function processResponseFile(filePath) {
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 { }
}
@@ -1177,64 +1230,53 @@ async function processResponseFile(filePath) {
// Stall-detection approvals: use VS Code commands as fallback (focus-dependent)
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`);
// 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) {
logToFile(`[RESPONSE] panel focus attempt ${i + 1} failed: ${e.message}`);
}
await new Promise(r => setTimeout(r, 500));
}
if (approved) {
const approveCommands = [
'antigravity.terminalCommand.run',
'antigravity.terminalCommand.accept',
'antigravity.command.accept',
'antigravity.agent.acceptAgentStep',
];
for (const cmd of approveCommands) {
// 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`);
const pendingDir = path.join(bridgePath, 'pending');
const responseDir = path.join(bridgePath, 'response');
if (!fs.existsSync(responseDir))
fs.mkdirSync(responseDir, { recursive: true });
let relayCount = 0;
try {
const files = fs.readdirSync(pendingDir).filter((f) => f.endsWith('.json'));
for (const f of files) {
try {
await vscode.commands.executeCommand(cmd);
logToFile(`[RESPONSE] cmd OK: ${cmd}`);
}
catch (e) {
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 { }
}
}
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) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd}${e.message}`);
}
}
catch (e) {
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);
// 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 { }
}
catch (e) {
const log = `[RESPONSE] error: ${e.message}`;
@@ -1382,6 +1424,7 @@ function writePendingApproval(data) {
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`);

File diff suppressed because one or more lines are too long