feat(bridge): 승인 감지 최적화 — latestToolCallStep 즉시 감지 + DOM scan 확장

- latestToolCallStep RPC 기반 즉시 감지 (30초 stall → 5초 poll)
- DOM scan 범위: findPanel() → document.body 확장
- Accept all/Reject all 리뷰 바 패턴 추가
- Stall detection을 100초 fallback으로 약화
- extractToolCommand/extractToolDescription 헬퍼 추가
- known-issues 5건 신규 추가
- start/services workflow: Python 전체 경로 + services.md 로딩

#task-258 #task-262
This commit is contained in:
2026-03-08 20:21:11 +09:00
parent 8ed1ece87a
commit 810fbcc114
6 changed files with 303 additions and 40 deletions

View File

@@ -576,7 +576,8 @@ 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:/^Accept/i, type:'agent_step'},
{re:/^Accept all$/i, type:'diff_review'},
{re:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'},
{re:/^Approve/i, type:'agent_step'},
{re:/^Continue$/i, type:'continue'},
@@ -584,7 +585,7 @@ function generateApprovalObserverScript(_port: number): string {
];
// Reject button patterns for finding the counterpart
var REJECT_RE=[/^reject$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i];
var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i];
// ── Stable button fingerprint (no getBoundingClientRect — scroll-safe) ──
function btnId(b,type){
@@ -650,11 +651,18 @@ function generateApprovalObserverScript(_port: number): string {
var now=Date.now();
var panel=findPanel();
if(!panel)return;
// Expand search: panel-scoped first, then full body for review bars
var searchRoots=[];
if(panel)searchRoots.push(panel);
// Always also scan body for diff review bar (Accept all/Reject all)
// which lives outside the agent panel in the editor notification area
if(document.body)searchRoots.push(document.body);
if(!searchRoots.length)return;
// Find ALL buttons in the panel
var allBtns=panel.querySelectorAll('button');
if(!allBtns.length)return;
var seen={}; // dedupe buttons across search roots
for(var r=0;r<searchRoots.length;r++){
var allBtns=searchRoots[r].querySelectorAll('button');
if(!allBtns.length)continue;
for(var j=0;j<allBtns.length;j++){
var b=allBtns[j];
@@ -707,6 +715,7 @@ function generateApprovalObserverScript(_port: number): string {
// Process ONE button per scan cycle (avoid flooding)
return;
}
} // end searchRoots loop
}
// ── Poll for Discord response ──
@@ -873,6 +882,8 @@ function setupMonitor() {
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
@@ -951,17 +962,62 @@ function setupMonitor() {
logToFile(`[POLL#${pollCount}] status=${statusStr} steps=${currentCount} delta=${delta}`);
}
// ── Stall-based approval detection ──
// INSIGHT: Both thinking and approval show RUNNING+delta=0.
// DIFFERENTIATOR: lastModifiedTime
// - Thinking: lastModifiedTime KEEPS CHANGING (server actively processing)
// - Approval wait: lastModifiedTime FROZEN (server idle, waiting for user)
// ── 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;
}
// DEBUG: dump session keys on first poll to find modTime field
// 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}, lastModifiedTimestamp=${(bestSession as any).lastModifiedTimestamp}, modifiedTime=${(bestSession as any).modifiedTime}`);
logToFile(`[DEBUG] lastModifiedTime=${bestSession.lastModifiedTime}`);
if (toolStep) logToFile(`[DEBUG] toolStep present, stepIndex=${toolStep.stepIndex}`);
}
const currentModTime = bestSession.lastModifiedTime || (bestSession as any).lastModifiedTimestamp || (bestSession as any).modifiedTime || '';
@@ -993,18 +1049,18 @@ function setupMonitor() {
const now = Date.now();
const cooldownOk = (now - lastPendingTime) > 60_000;
if (consecutiveIdleCount >= 6 && sawRunningAfterPending && cooldownOk) {
// 6 polls × 5s = 30 seconds of FROZEN stall = approval waiting
if (consecutiveIdleCount >= 20 && sawRunningAfterPending && cooldownOk) {
// 20 polls × 5s = 100 seconds — fallback only
lastPendingStepIndex = currentCount;
lastPendingTime = now;
sawRunningAfterPending = false;
const command = `Stall at step ${currentCount}`;
const description = `승인 대기 감지 (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
const command = `Stall at step ${currentCount} (fallback)`;
const description = `승인 대기 감지 — fallback (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
logToFile(`[STALL] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
logToFile(`[STALL-FALLBACK] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
writePendingApproval({ conversation_id: activeSessionId, command, description });
} else if (consecutiveIdleCount === 6) {
} else if (consecutiveIdleCount === 20) {
const reasons = [];
if (!sawRunningAfterPending) reasons.push('needDelta>0');
if (!cooldownOk) reasons.push(`cooldown(${Math.round((60000 - (now - lastPendingTime)) / 1000)}s)`);
@@ -1239,8 +1295,52 @@ function filterEphemeral(text: string): string | null {
return text;
}
/** Extract human-readable command from a tool call step's data. */
function extractToolCommand(stepData: any): string {
// Try common step data shapes from protobuf
if (stepData.runCommand) {
return stepData.runCommand.commandLine || stepData.runCommand.command || 'Run Command';
}
if (stepData.writeToFile) {
const target = stepData.writeToFile.targetFile || stepData.writeToFile.filePath || 'file';
return `Write: ${target.split(/[\\/]/).pop()}`;
}
if (stepData.codeAction) {
const fp = stepData.codeAction.filePath || '';
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
}
if (stepData.replaceFileContent || stepData.multiReplaceFileContent) {
const d = stepData.replaceFileContent || stepData.multiReplaceFileContent;
const fp = d.targetFile || d.filePath || '';
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
}
if (stepData.sendCommandInput) {
return `Send Input: ${(stepData.sendCommandInput.input || '').substring(0, 50)}`;
}
// Generic fallback: use first key name
const keys = Object.keys(stepData).filter(k => k !== 'status' && k !== 'stepStatus');
return keys.length > 0 ? keys[0] : 'Unknown tool call';
}
/** Extract description from a tool call step for Discord display. */
function extractToolDescription(stepData: any, sessionTitle: string, stepIndex: number): string {
const parts = [`Step #${stepIndex}`, `Session: "${sessionTitle}"`];
// Try to get code/command content for context
if (stepData.runCommand) {
const cmd = stepData.runCommand.commandLine || stepData.runCommand.command || '';
if (cmd) parts.push(`Command: ${cmd.substring(0, 200)}`);
}
if (stepData.writeToFile?.targetFile) {
parts.push(`File: ${stepData.writeToFile.targetFile}`);
}
if (stepData.codeAction?.filePath) {
parts.push(`File: ${stepData.codeAction.filePath}`);
}
return parts.join('\n');
}
/** Write a pending approval file matching Bot's ApprovalRequest dataclass. */
function writePendingApproval(data: { conversation_id: string; command: string; description: string }) {
function writePendingApproval(data: { conversation_id: string; command: string; description: string; step_type?: string; step_index?: number }) {
try {
const pendingDir = path.join(bridgePath, 'pending');
if (!fs.existsSync(pendingDir)) { fs.mkdirSync(pendingDir, { recursive: true }); }
@@ -1254,6 +1354,8 @@ function writePendingApproval(data: { conversation_id: string; command: string;
status: 'pending',
discord_message_id: 0,
project_name: projectName,
...(data.step_type ? { step_type: data.step_type } : {}),
...(data.step_index !== undefined ? { step_index: data.step_index } : {}),
};
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
console.log(`Gravity Bridge: pending approval written → ${id}.json`);