fix(bridge): safer stall detection + VS Code command-based approval

Stall detection fixes:
- Threshold 2→6 polls (30s minimum stall before triggering)
- Added lastModifiedTime tracking (both stepCount AND modTime must freeze)
- Cooldown 30s→60s between pending writes
- Track lastPendingStepCount to prevent retrigger for same stall

Approval handler fixes:
- Replace HandleCascadeUserInteraction RPC with VS Code commands
- Sequential fallback: acceptAgentStep → command.accept → terminalCommand.run
- Same pattern for reject: rejectAgentStep → command.reject → terminalCommand.reject
- Removed SDK dependency check (VS Code commands work without SDK)
This commit is contained in:
2026-03-08 08:14:35 +09:00
parent 9b9c9c71fe
commit f1f9a0b40b
3 changed files with 117 additions and 57 deletions

View File

@@ -275,8 +275,10 @@ function setupMonitor() {
let lastKnownStepCount = 0;
let lastNotifyStepIndex = -1;
let lastTaskStepIndex = -1;
let stallCount = 0; // consecutive polls with no step change while RUNNING
let stallCount = 0; // consecutive polls with frozen stepCount + lastModifiedTime
let lastPendingAt = 0; // timestamp of last pending approval write (dedup)
let lastPendingStepCount = -1; // step count when last pending was written (prevent retrigger)
let prevModTime = ''; // track lastModifiedTime changes
setInterval(async () => {
pollCount++;
try {
@@ -301,6 +303,7 @@ function setupMonitor() {
const currentCount = bestSession.stepCount || 0;
const currentTitle = (bestSession.summary || 'Untitled').substring(0, 50);
const isRunning = String(bestSession.status || '').includes('RUNNING');
const currentModTime = bestSession.lastModifiedTime || '';
// Session changed?
if (bestSessionId !== activeSessionId) {
activeSessionId = bestSessionId;
@@ -309,17 +312,23 @@ function setupMonitor() {
lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1;
lastTaskStepIndex = bestSession.latestTaskBoundaryStep?.stepIndex ?? -1;
stallCount = 0;
prevModTime = currentModTime;
writeRegistration(activeSessionId);
console.log(`Gravity Bridge: [POLL#${pollCount}] session: ${activeSessionId.substring(0, 8)} "${currentTitle}" steps=${currentCount} ${isRunning ? 'RUNNING' : 'idle'}`);
return;
}
// ── STALL DETECTION for WAITING steps ──
// If stepCount is frozen while status is RUNNING → AI is likely waiting for user approval
if (currentCount === lastKnownStepCount && isRunning) {
// TRUE WAITING: stepCount frozen AND lastModifiedTime frozen AND status RUNNING
// FALSE STALL: stepCount frozen but lastModifiedTime still changing (AI thinking/processing)
const timeAlsoFrozen = currentModTime === prevModTime;
prevModTime = currentModTime;
if (currentCount === lastKnownStepCount && isRunning && timeAlsoFrozen) {
stallCount++;
// After 2 consecutive stalls (~10s), write pending approval (once per stall period)
if (stallCount === 2 && (Date.now() - lastPendingAt) > 30000) {
// After 6 consecutive stalls (~30s), write pending approval
// Only if: cooldown elapsed AND not same step count as last pending
if (stallCount === 6 && (Date.now() - lastPendingAt) > 60000 && currentCount !== lastPendingStepCount) {
lastPendingAt = Date.now();
lastPendingStepCount = currentCount;
console.log(`Gravity Bridge: [POLL#${pollCount}] STALL detected (${stallCount} polls, steps=${currentCount}) → writing pending`);
writePendingApproval({
conversation_id: activeSessionId,
@@ -327,12 +336,9 @@ function setupMonitor() {
description: `⏳ **AI가 사용자 확인을 기다리고 있습니다**\n\n세션: ${currentTitle}\nstep: ${currentCount}\n\n✅ 승인 또는 ❌ 거부를 선택하세요.`,
});
}
if (pollCount % 20 === 0) {
console.log(`Gravity Bridge: [POLL#${pollCount}] stall=${stallCount} steps=${currentCount} RUNNING`);
}
}
else {
// Step count changed or not running — reset stall
// Reset stall if anything changed
if (stallCount > 0) {
console.log(`Gravity Bridge: [POLL#${pollCount}] stall cleared (was ${stallCount})`);
}
@@ -404,10 +410,6 @@ async function processResponseFile(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const resp = JSON.parse(content);
console.log(`Gravity Bridge: [RESPONSE] request_id=${resp.request_id} approved=${resp.approved}`);
if (!sdk) {
console.log('Gravity Bridge: [RESPONSE] SDK not available');
return;
}
// Find matching pending request for session_id
const pendingDir = path.join(bridgePath, 'pending');
const pendingFile = path.join(pendingDir, `${resp.request_id}.json`);
@@ -419,27 +421,55 @@ async function processResponseFile(filePath) {
}
catch { }
}
if (sessionId && resp.approved) {
if (resp.approved) {
// TRY ALL APPROVAL METHODS sequentially until one works
// These are the CORRECT VS Code commands from the SDK:
// 1. antigravity.agent.acceptAgentStep — generic agent step (file edits, etc.)
// 2. antigravity.command.accept — non-terminal command acceptance
// 3. antigravity.terminalCommand.run — terminal command execution
const approvalCommands = [
{ cmd: 'antigravity.agent.acceptAgentStep', label: 'acceptAgentStep' },
{ cmd: 'antigravity.command.accept', label: 'command.accept' },
{ cmd: 'antigravity.terminalCommand.run', label: 'terminalCommand.run' },
];
let approved = false;
for (const { cmd, label } of approvalCommands) {
try {
await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
cascadeId: sessionId,
approved: true,
});
console.log('Gravity Bridge: [RESPONSE] ✅ approved via HandleCascadeUserInteraction');
await vscode.commands.executeCommand(cmd);
console.log(`Gravity Bridge: [RESPONSE] ✅ approved via ${label}`);
approved = true;
break;
}
catch (e) {
console.log(`Gravity Bridge: [RESPONSE] HandleCascadeUserInteraction failed: ${e.message}`);
try {
await sdk.ls.rawRPC('ResolveOutstandingSteps', { cascadeId: sessionId });
console.log('Gravity Bridge: [RESPONSE] ✅ approved via ResolveOutstandingSteps');
console.log(`Gravity Bridge: [RESPONSE] ${label} failed: ${e.message}`);
}
catch (e2) {
console.log(`Gravity Bridge: [RESPONSE] ResolveOutstandingSteps also failed: ${e2.message}`);
}
if (!approved) {
console.log('Gravity Bridge: [RESPONSE] ⚠️ all approval methods failed');
}
}
else {
console.log(`Gravity Bridge: [RESPONSE] ${resp.approved ? '✅' : '❌'} (session=${sessionId || 'unknown'})`);
// REJECT — try all reject commands
const rejectCommands = [
{ cmd: 'antigravity.agent.rejectAgentStep', label: 'rejectAgentStep' },
{ cmd: 'antigravity.command.reject', label: 'command.reject' },
{ cmd: 'antigravity.terminalCommand.reject', label: 'terminalCommand.reject' },
];
let rejected = false;
for (const { cmd, label } of rejectCommands) {
try {
await vscode.commands.executeCommand(cmd);
console.log(`Gravity Bridge: [RESPONSE] ❌ rejected via ${label}`);
rejected = true;
break;
}
catch (e) {
console.log(`Gravity Bridge: [RESPONSE] ${label} failed: ${e.message}`);
}
}
if (!rejected) {
console.log('Gravity Bridge: [RESPONSE] ⚠️ all reject methods failed');
}
}
try {
fs.unlinkSync(filePath);

File diff suppressed because one or more lines are too long

View File

@@ -244,8 +244,10 @@ function setupMonitor() {
let lastKnownStepCount = 0;
let lastNotifyStepIndex = -1;
let lastTaskStepIndex = -1;
let stallCount = 0; // consecutive polls with no step change while RUNNING
let stallCount = 0; // consecutive polls with frozen stepCount + lastModifiedTime
let lastPendingAt = 0; // timestamp of last pending approval write (dedup)
let lastPendingStepCount = -1; // step count when last pending was written (prevent retrigger)
let prevModTime = ''; // track lastModifiedTime changes
setInterval(async () => {
pollCount++;
@@ -271,6 +273,7 @@ function setupMonitor() {
const currentCount = bestSession.stepCount || 0;
const currentTitle = (bestSession.summary || 'Untitled').substring(0, 50);
const isRunning = String(bestSession.status || '').includes('RUNNING');
const currentModTime = bestSession.lastModifiedTime || '';
// Session changed?
if (bestSessionId !== activeSessionId) {
@@ -280,18 +283,25 @@ function setupMonitor() {
lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1;
lastTaskStepIndex = bestSession.latestTaskBoundaryStep?.stepIndex ?? -1;
stallCount = 0;
prevModTime = currentModTime;
writeRegistration(activeSessionId);
console.log(`Gravity Bridge: [POLL#${pollCount}] session: ${activeSessionId.substring(0, 8)} "${currentTitle}" steps=${currentCount} ${isRunning ? 'RUNNING' : 'idle'}`);
return;
}
// ── STALL DETECTION for WAITING steps ──
// If stepCount is frozen while status is RUNNING → AI is likely waiting for user approval
if (currentCount === lastKnownStepCount && isRunning) {
// TRUE WAITING: stepCount frozen AND lastModifiedTime frozen AND status RUNNING
// FALSE STALL: stepCount frozen but lastModifiedTime still changing (AI thinking/processing)
const timeAlsoFrozen = currentModTime === prevModTime;
prevModTime = currentModTime;
if (currentCount === lastKnownStepCount && isRunning && timeAlsoFrozen) {
stallCount++;
// After 2 consecutive stalls (~10s), write pending approval (once per stall period)
if (stallCount === 2 && (Date.now() - lastPendingAt) > 30000) {
// After 6 consecutive stalls (~30s), write pending approval
// Only if: cooldown elapsed AND not same step count as last pending
if (stallCount === 6 && (Date.now() - lastPendingAt) > 60000 && currentCount !== lastPendingStepCount) {
lastPendingAt = Date.now();
lastPendingStepCount = currentCount;
console.log(`Gravity Bridge: [POLL#${pollCount}] STALL detected (${stallCount} polls, steps=${currentCount}) → writing pending`);
writePendingApproval({
conversation_id: activeSessionId,
@@ -299,11 +309,8 @@ function setupMonitor() {
description: `⏳ **AI가 사용자 확인을 기다리고 있습니다**\n\n세션: ${currentTitle}\nstep: ${currentCount}\n\n✅ 승인 또는 ❌ 거부를 선택하세요.`,
});
}
if (pollCount % 20 === 0) {
console.log(`Gravity Bridge: [POLL#${pollCount}] stall=${stallCount} steps=${currentCount} RUNNING`);
}
} else {
// Step count changed or not running — reset stall
// Reset stall if anything changed
if (stallCount > 0) {
console.log(`Gravity Bridge: [POLL#${pollCount}] stall cleared (was ${stallCount})`);
}
@@ -383,11 +390,6 @@ async function processResponseFile(filePath: string) {
const resp = JSON.parse(content);
console.log(`Gravity Bridge: [RESPONSE] request_id=${resp.request_id} approved=${resp.approved}`);
if (!sdk) {
console.log('Gravity Bridge: [RESPONSE] SDK not available');
return;
}
// Find matching pending request for session_id
const pendingDir = path.join(bridgePath, 'pending');
const pendingFile = path.join(pendingDir, `${resp.request_id}.json`);
@@ -399,24 +401,52 @@ async function processResponseFile(filePath: string) {
} catch { }
}
if (sessionId && resp.approved) {
if (resp.approved) {
// TRY ALL APPROVAL METHODS sequentially until one works
// These are the CORRECT VS Code commands from the SDK:
// 1. antigravity.agent.acceptAgentStep — generic agent step (file edits, etc.)
// 2. antigravity.command.accept — non-terminal command acceptance
// 3. antigravity.terminalCommand.run — terminal command execution
const approvalCommands = [
{ cmd: 'antigravity.agent.acceptAgentStep', label: 'acceptAgentStep' },
{ cmd: 'antigravity.command.accept', label: 'command.accept' },
{ cmd: 'antigravity.terminalCommand.run', label: 'terminalCommand.run' },
];
let approved = false;
for (const { cmd, label } of approvalCommands) {
try {
await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
cascadeId: sessionId,
approved: true,
});
console.log('Gravity Bridge: [RESPONSE] ✅ approved via HandleCascadeUserInteraction');
await vscode.commands.executeCommand(cmd);
console.log(`Gravity Bridge: [RESPONSE] ✅ approved via ${label}`);
approved = true;
break;
} catch (e: any) {
console.log(`Gravity Bridge: [RESPONSE] HandleCascadeUserInteraction failed: ${e.message}`);
try {
await sdk.ls.rawRPC('ResolveOutstandingSteps', { cascadeId: sessionId });
console.log('Gravity Bridge: [RESPONSE] ✅ approved via ResolveOutstandingSteps');
} catch (e2: any) {
console.log(`Gravity Bridge: [RESPONSE] ResolveOutstandingSteps also failed: ${e2.message}`);
console.log(`Gravity Bridge: [RESPONSE] ${label} failed: ${e.message}`);
}
}
if (!approved) {
console.log('Gravity Bridge: [RESPONSE] ⚠️ all approval methods failed');
}
} else {
console.log(`Gravity Bridge: [RESPONSE] ${resp.approved ? '✅' : '❌'} (session=${sessionId || 'unknown'})`);
// REJECT — try all reject commands
const rejectCommands = [
{ cmd: 'antigravity.agent.rejectAgentStep', label: 'rejectAgentStep' },
{ cmd: 'antigravity.command.reject', label: 'command.reject' },
{ cmd: 'antigravity.terminalCommand.reject', label: 'terminalCommand.reject' },
];
let rejected = false;
for (const { cmd, label } of rejectCommands) {
try {
await vscode.commands.executeCommand(cmd);
console.log(`Gravity Bridge: [RESPONSE] ❌ rejected via ${label}`);
rejected = true;
break;
} catch (e: any) {
console.log(`Gravity Bridge: [RESPONSE] ${label} failed: ${e.message}`);
}
}
if (!rejected) {
console.log('Gravity Bridge: [RESPONSE] ⚠️ all reject methods failed');
}
}
try { fs.unlinkSync(filePath); } catch { }