fix(bridge): approval flow robustness — pending cleanup, MERGE dedup, false positive filter, auto_resolve, 30min timeout
This commit is contained in:
@@ -425,6 +425,7 @@ let observerHttpServer = null;
|
||||
const pendingResponses = new Map();
|
||||
// Click trigger: extension sets this, renderer polls and clicks button
|
||||
let clickTrigger = null;
|
||||
let sessionStalled = false; // true when session is stalled waiting for approval
|
||||
// Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back
|
||||
let deepInspectRequested = false;
|
||||
let deepInspectResult = null;
|
||||
@@ -459,6 +460,22 @@ function startObserverHttpBridge() {
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
// ── Server-side false positive filter ──
|
||||
const cmd = (data.command || '').trim();
|
||||
const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it)$/i;
|
||||
if (FALSE_POSITIVE_RE.test(cmd)) {
|
||||
logToFile(`[HTTP] filtered false positive: "${cmd}"`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, filtered: true }));
|
||||
return;
|
||||
}
|
||||
// "Run" button → only accept if session is actually stalled (waiting for approval)
|
||||
if (/^Run/i.test(cmd) && !sessionStalled) {
|
||||
logToFile(`[HTTP] filtered "Run" — session not stalled`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, filtered: true }));
|
||||
return;
|
||||
}
|
||||
const rid = data.request_id || Date.now().toString();
|
||||
// Write pending file for Discord bot
|
||||
const pendingDir = path.join(bridgePath, 'pending');
|
||||
@@ -889,8 +906,6 @@ function generateApprovalObserverScript(_port) {
|
||||
{re:/^Accept$/i, type:'agent_step'},
|
||||
{re:/^Allow/i, type:'permission'},
|
||||
{re:/^Approve/i, type:'agent_step'},
|
||||
{re:/^Continue$/i, type:'continue'},
|
||||
{re:/^Proceed$/i, type:'continue'},
|
||||
{re:/^Retry$/i, type:'error_recovery'},
|
||||
{re:/^Dismiss$/i, type:'error_recovery'},
|
||||
];
|
||||
@@ -1393,6 +1408,29 @@ function setupMonitor() {
|
||||
logToFile(`[STALL-DBG] idle=${consecutiveIdleCount} modTime='${currentModTime}' changed=${modTimeChanged}`);
|
||||
}
|
||||
if (delta > 0) {
|
||||
sessionStalled = false;
|
||||
// Steps progressed — if we had a pending approval, it was handled in AG directly
|
||||
if (!sawRunningAfterPending && lastPendingStepIndex >= 0) {
|
||||
// Mark pending as auto_resolved so bot can update Discord message
|
||||
try {
|
||||
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending'))
|
||||
.filter((f) => f.endsWith('.json'));
|
||||
for (const pf of pendingFiles) {
|
||||
const pfPath = path.join(bridgePath, 'pending', pf);
|
||||
const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
|
||||
if (pd.status === 'pending' && pd.step_index === lastPendingStepIndex) {
|
||||
pd.status = 'auto_resolved';
|
||||
fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
|
||||
logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${pf}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
logToFile(`[AUTO-RESOLVE] error: ${e.message}`);
|
||||
}
|
||||
lastPendingStepIndex = -1;
|
||||
}
|
||||
consecutiveIdleCount = 0;
|
||||
sawRunningAfterPending = true;
|
||||
stallProbed = false; // allow re-probe on next stall
|
||||
@@ -1410,6 +1448,8 @@ function setupMonitor() {
|
||||
else {
|
||||
// lastModifiedTime frozen = real stall (approval waiting)
|
||||
consecutiveIdleCount++;
|
||||
if (consecutiveIdleCount >= 1)
|
||||
sessionStalled = true;
|
||||
}
|
||||
lastModTime = currentModTime;
|
||||
// ── Step probe: on stall, fetch latest step via cascadeId (retry until WAITING found) ──
|
||||
@@ -1490,6 +1530,57 @@ function setupMonitor() {
|
||||
// (stall fallback was generating false positives and is now redundant)
|
||||
}
|
||||
else if (!isRunning) {
|
||||
// ── Error detection: probe when session transitions from RUNNING→idle ──
|
||||
if (consecutiveIdleCount > 0 && !stallProbed) {
|
||||
// Was running, now idle — possible error. Probe once.
|
||||
try {
|
||||
const stepsResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
||||
cascadeId: bestSessionId,
|
||||
});
|
||||
if (stepsResp?.steps?.length > 0) {
|
||||
const steps = stepsResp.steps;
|
||||
// Check last 3 steps for error/failed status
|
||||
for (let si = steps.length - 1; si >= Math.max(0, steps.length - 3); si--) {
|
||||
const step = steps[si];
|
||||
const stepStatus = step?.status || '';
|
||||
const stepType = step?.type || '';
|
||||
if (stepStatus.includes('ERROR') || stepStatus.includes('FAILED')) {
|
||||
const toolCall = step?.metadata?.toolCall;
|
||||
const toolName = toolCall?.name || stepType.replace('CORTEX_STEP_TYPE_', '').toLowerCase();
|
||||
let command = `⚠️ Error: ${toolName}`;
|
||||
if (toolCall?.argumentsJson) {
|
||||
try {
|
||||
const args = JSON.parse(toolCall.argumentsJson);
|
||||
if (args.CommandLine)
|
||||
command = `⚠️ Error: ${args.CommandLine.substring(0, 100)}`;
|
||||
else if (args.TargetFile)
|
||||
command = `⚠️ Error: ${args.TargetFile.split(/[\\/]/).pop()}`;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
const description = `Step #${si} ${stepStatus} — Retry?`;
|
||||
logToFile(`[STEP-PROBE] ★ ERROR! step=${si} status=${stepStatus} type=${stepType}`);
|
||||
if (si !== lastPendingStepIndex) {
|
||||
stallProbed = true;
|
||||
lastPendingStepIndex = si;
|
||||
writePendingApproval({
|
||||
conversation_id: activeSessionId,
|
||||
command,
|
||||
description,
|
||||
step_type: 'error_recovery',
|
||||
step_index: si,
|
||||
source: 'step_probe_error',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
logToFile(`[STEP-PROBE-ERR] error check: ${e.message}`);
|
||||
}
|
||||
}
|
||||
consecutiveIdleCount = 0;
|
||||
lastModTime = currentModTime;
|
||||
}
|
||||
@@ -1751,7 +1842,7 @@ function writePendingApproval(data) {
|
||||
if (!fs.existsSync(pendingDir)) {
|
||||
fs.mkdirSync(pendingDir, { recursive: true });
|
||||
}
|
||||
// ── Dedup: skip if DOM observer already created a pending for same action recently ──
|
||||
// ── Dedup: if DOM observer already created a "Run"-only pending, MERGE detailed info into it ──
|
||||
const nowMs = Date.now();
|
||||
const DEDUP_WINDOW_MS = 15_000; // 15 second dedup window
|
||||
try {
|
||||
@@ -1762,7 +1853,16 @@ function writePendingApproval(data) {
|
||||
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)`);
|
||||
// MERGE: update DOM observer pending with detailed step_probe info
|
||||
existing.command = data.command;
|
||||
existing.description = data.description;
|
||||
if (data.step_type)
|
||||
existing.step_type = data.step_type;
|
||||
if (data.step_index !== undefined)
|
||||
existing.step_index = data.step_index;
|
||||
existing.source = 'dom_observer+step_probe'; // mark as merged
|
||||
fs.writeFileSync(efPath, JSON.stringify(existing, null, 2), 'utf-8');
|
||||
logToFile(`[DEDUP] MERGED step_probe info into DOM pending: ${ef} cmd="${data.command.substring(0, 60)}"`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user