fix(bridge): approval flow tuning — dedup + text cleanup + stall fallback removal + safe reject #task-256

This commit is contained in:
2026-03-09 22:31:44 +09:00
parent 520d36ea43
commit 18b3734c02
5 changed files with 117 additions and 91 deletions

View File

@@ -569,29 +569,29 @@ function startObserverHttpBridge(): Promise<number> {
// Listen on deterministic port (derived from projectName), fallback to random
deterministicPort = getDeterministicPort(projectName);
const tryListen = (targetPort: number) => {
server.listen(targetPort, '127.0.0.1', () => {
const port = server.address().port;
observerHttpServer = server;
logToFile(`[HTTP] bridge server started on port ${port}`);
server.listen(targetPort, '127.0.0.1', () => {
const port = server.address().port;
observerHttpServer = server;
logToFile(`[HTTP] bridge server started on port ${port}`);
// Write port to shared ports JSON (multi-bridge support)
const patcher = (sdk.integration as any)?._patcher;
if (patcher && typeof patcher.getWorkbenchDir === 'function') {
const workbenchDir = patcher.getWorkbenchDir();
const portsFile = path.join(workbenchDir, 'ag-bridge-ports.json');
let portsData: Record<string, number> = {};
try {
if (fs.existsSync(portsFile)) {
portsData = JSON.parse(fs.readFileSync(portsFile, 'utf-8'));
}
} catch { }
portsData[projectName] = port;
fs.writeFileSync(portsFile, JSON.stringify(portsData), 'utf-8');
logToFile(`[HTTP] ports JSON updated → ${portsFile} (${projectName}=${port})`);
}
// Write port to shared ports JSON (multi-bridge support)
const patcher = (sdk.integration as any)?._patcher;
if (patcher && typeof patcher.getWorkbenchDir === 'function') {
const workbenchDir = patcher.getWorkbenchDir();
const portsFile = path.join(workbenchDir, 'ag-bridge-ports.json');
let portsData: Record<string, number> = {};
try {
if (fs.existsSync(portsFile)) {
portsData = JSON.parse(fs.readFileSync(portsFile, 'utf-8'));
}
} catch { }
portsData[projectName] = port;
fs.writeFileSync(portsFile, JSON.stringify(portsData), 'utf-8');
logToFile(`[HTTP] ports JSON updated → ${portsFile} (${projectName}=${port})`);
}
resolve(port);
});
resolve(port);
});
};
server.on('error', (e: any) => {
@@ -957,6 +957,9 @@ function generateApprovalObserverScript(_port: number): string {
var txt=(b.textContent||'').trim();
if(!txt)continue;
// Strip keyboard shortcut suffixes (e.g. "RunAlt+↵" → "Run")
txt=txt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/,'').trim();
if(!txt)continue;
// Match against patterns
var matchedType=null;
@@ -1406,7 +1409,7 @@ function setupMonitor() {
if (steps.length < currentCount) {
logToFile(`[STEP-PROBE] ⚠️ 775-limit hit! steps=${steps.length} < 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--) {
@@ -1420,7 +1423,7 @@ function setupMonitor() {
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 {
@@ -1465,27 +1468,8 @@ function setupMonitor() {
}
}
const now = Date.now();
const cooldownOk = (now - lastPendingTime) > 60_000;
const fallbackThreshold = (currentCount > 770) ? 4 : 8; // 775-limit: faster fallback
if (consecutiveIdleCount >= fallbackThreshold && sawRunningAfterPending && cooldownOk) {
// Dynamic fallback: 20s (>770 steps) or 40s (normal)
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, source: 'stall_fallback' });
} else if (consecutiveIdleCount === fallbackThreshold) {
const reasons = [];
if (!sawRunningAfterPending) reasons.push('needDelta>0');
if (!cooldownOk) reasons.push(`cooldown(${Math.round((60000 - (now - lastPendingTime)) / 1000)}s)`);
if (reasons.length > 0) logToFile(`[STALL] SKIP: ${reasons.join(', ')}`);
}
// Stall fallback REMOVED — step probe is sole fallback source
// (stall fallback was generating false positives and is now redundant)
} else if (!isRunning) {
consecutiveIdleCount = 0;
lastModTime = currentModTime;
@@ -1601,18 +1585,20 @@ async function processResponseFile(filePath: string) {
// DOM observer path: renderer polls /response/:rid and clicks directly
logToFile(`[RESPONSE] renderer-handled approval (rid=${resp.request_id})`);
} else {
// Step probe / stall path: try all approval strategies
logToFile(`[RESPONSE] step_probe/stall → trying multi-strategy approval`);
const approvalResult = await tryApprovalStrategies(approved, sessionId);
logToFile(`[RESPONSE] approval result: ${approvalResult}`);
// Step probe path: approve → trigger renderer click, reject → log only
if (approved) {
logToFile(`[RESPONSE] step_probe → approve via trigger-click`);
clickTrigger = { action: 'approve' as const, timestamp: Date.now() };
} else {
// SAFE: reject logs only — NO ResolveOutstandingSteps (it cancels AI work!)
logToFile(`[RESPONSE] step_probe → reject (log only, no destructive action)`);
}
}
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
// Cleanup response file — BUT NOT for DOM observer!
if (!isDomObserver) {
try { fs.unlinkSync(filePath); } catch { }
}
// Cleanup response file
try { fs.unlinkSync(filePath); } catch { }
} catch (e: any) {
const log = `[RESPONSE] error: ${e.message}`;
console.log(`Gravity Bridge: ${log}`);
@@ -1738,13 +1724,34 @@ function writePendingApproval(data: { conversation_id: string; command: string;
try {
const pendingDir = path.join(bridgePath, 'pending');
if (!fs.existsSync(pendingDir)) { fs.mkdirSync(pendingDir, { recursive: true }); }
const id = Date.now().toString();
// ── Dedup: skip if DOM observer already created a pending for same action recently ──
const nowMs = Date.now();
const DEDUP_WINDOW_MS = 15_000; // 15 second dedup window
try {
const existingFiles = fs.readdirSync(pendingDir).filter((f: string) => f.endsWith('.json'));
for (const ef of existingFiles) {
const efPath = path.join(pendingDir, ef);
const existing = JSON.parse(fs.readFileSync(efPath, 'utf-8'));
if (existing.source === 'dom_observer' && existing.status === 'pending') {
const age = nowMs - (existing.timestamp * 1000);
if (age < DEDUP_WINDOW_MS && age >= 0) {
logToFile(`[DEDUP] skip step_probe pending — DOM observer pending exists: ${ef} (${Math.round(age/1000)}s ago)`);
return;
}
}
}
} catch (dedupErr: any) {
logToFile(`[DEDUP] check error (non-fatal): ${dedupErr.message}`);
}
const id = nowMs.toString();
const payload = {
request_id: id,
conversation_id: data.conversation_id,
command: data.command,
description: data.description,
timestamp: Date.now() / 1000,
timestamp: nowMs / 1000,
status: 'pending',
discord_message_id: 0,
project_name: projectName,
@@ -1908,7 +1915,7 @@ export async function activate(context: vscode.ExtensionContext) {
logToFile(`[CMD-DISCOVERY] Total antigravity.* commands: ${agCmds.length}`);
// Log approval-related commands specifically
const approvalKeywords = ['accept', 'reject', 'approve', 'terminal', 'agent', 'cascade', 'step', 'run', 'command.'];
const relevantCmds = agCmds.filter((c: string) =>
const relevantCmds = agCmds.filter((c: string) =>
approvalKeywords.some(kw => c.toLowerCase().includes(kw))
);
logToFile(`[CMD-DISCOVERY] Approval-related commands (${relevantCmds.length}):`);