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:
@@ -117,3 +117,30 @@
|
||||
- **원인**: Electron 렌더러 프로세스에서 동기 XHR이 보안 정책에 의해 차단됨
|
||||
- **해결**: `fetch()` + `AbortSignal.timeout(2000)` 비동기 방식으로 교체 (`tryPingAsync`)
|
||||
- **주의**: `async/await` 사용 불가 (ES5 환경). `.then()` 체이닝으로 구현
|
||||
|
||||
### [2026-03-08] DOM Observer — Run 버튼 감지 불가 (webview iframe 격리)
|
||||
- **증상**: Allow Once/Allow This Conversation는 감지되나 Run/Accept 버튼은 감지 안 됨
|
||||
- **원인**: Trust/permission 버튼은 워크벤치 외부 DOM에 렌더링, Run/Accept는 **Antigravity 채팅 webview iframe** 내부의 별도 DOM에 렌더링. 렌더러 스크립트의 `document.querySelector()`는 iframe 내부 접근 불가
|
||||
- **해결**: Run 버튼은 DOM Observer가 아닌 **`latestToolCallStep` RPC 기반** 즉시 감지로 대체
|
||||
- **주의**: webview iframe에 스크립트 주입은 Electron `executeJavaScript()`로 가능하나, 현재 RPC 방식이 더 안정적
|
||||
|
||||
### [2026-03-08] Accept all/Reject all 리뷰 바 — agent 패널 밖 DOM
|
||||
- **증상**: 코드 변경 리뷰 바(Accept all/Reject all)가 DOM Observer에 감지 안 됨
|
||||
- **원인**: `scan()` 함수가 `findPanel()` (`.antigravity-agent-side-panel` 등) 내부만 검색. 리뷰 바는 에디터/notification 영역에 렌더링되어 패널 밖에 있음
|
||||
- **해결**: `scan()` 검색 범위를 `document.body` 전체로 확장, `Accept all` / `Reject all` 패턴 추가
|
||||
- **주의**: 패널+body 이중 검색 시 dedupe 필요 (같은 버튼이 두 번 잡힐 수 있음)
|
||||
|
||||
### [2026-03-08] latestToolCallStep — 즉시 승인 감지 (stall 30초 대체)
|
||||
- **증상**: 이전 stall-based 감지는 RUNNING+delta=0+modTime frozen 30초 후에야 pending 생성
|
||||
- **원인**: `GetAllCascadeTrajectories` 응답의 `latestToolCallStep` 필드를 전혀 활용하지 않았음
|
||||
- **해결**: `latestToolCallStep.step`의 status 필드에서 `WAITING` 여부를 직접 체크 → 5초 poll 1회에 즉시 감지. Stall detection은 100초 fallback으로 유지
|
||||
- **주의**: `latestToolCallStep`의 정확한 protobuf 구조(status 필드 위치)는 런타임 덤프로 확정 필요 — 첫 실행 시 `[TOOL-STEP]` 로그 확인
|
||||
|
||||
### [2026-03-08] Extension 재설치 안전성 — 자동 패치 메커니즘
|
||||
- **증상**: Antigravity 삭제 후 재설치 시 렌더러 스크립트가 동작 안 함
|
||||
- **원인**: 재설치 시 HTML/product.json이 원본으로 리셋됨
|
||||
- **해결**: Extension의 `setupApprovalObserver()`가 **자동으로** 모든 패치를 수행:
|
||||
1. `workbench.html` + `workbench-jetski-agent.html` 인라인 스크립트 삽입
|
||||
2. `product.json` SHA256 체크섬 자동 업데이트
|
||||
3. HTTP bridge 서버 시작 + 결정론적 포트
|
||||
- **주의**: 패치 후 반드시 **Antigravity 풀 재시작** 필요 (Reload Window 불가). Extension VSIX만 설치하면 수동 패치 불필요
|
||||
|
||||
@@ -18,3 +18,5 @@
|
||||
| 14 | 17:01~17:38 | **근본 원인 발견**: product.json 체크섬 불일치 → vscode-file:// 원본 캐시 서빙. 체크섬 수동 업데이트로 수정 | - | 🔧 |
|
||||
| 15 | 17:50~18:30 | **v0.3.5**: 포트 디스커버리 수정 (결정론적 포트 + 하드코딩), 인라인 스크립트 전환 (`<script src>` → `<script>inline</script>`), product.json 자동 체크섬 업데이트 | - | 🔧 |
|
||||
| 16 | 19:00~19:48 | 렌더러 스크립트 로딩 디버깅: sync XHR→async fetch 변환, 설치경로 불일치 발견, vscode-file:// 커스텀 파일 서빙 불가 확인, Electron 풀 재시작 필요 발견 | - | 🔧 |
|
||||
| 17 | 19:53~20:00 | **AG 재시작 성공**: GB Observer Bridge connected (port 34332), Allow Once/Allow This Conversation 감지 정상 동작 확인 | - | ✅ |
|
||||
| 18 | 20:00~20:15 | **승인 감지 최적화**: latestToolCallStep 즉시 감지 (30초→5초), DOM scan 범위 확장 (Accept all/Reject all), stall→100초 fallback | - | 🔧 |
|
||||
|
||||
36
docs/devlog/entries/20260308-018.md
Normal file
36
docs/devlog/entries/20260308-018.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 승인 감지 최적화 + GB Observer 동작 확인
|
||||
|
||||
- **시간**: 2026-03-08 19:53~20:15
|
||||
- **Commit**: (이 세션)
|
||||
- **Vikunja**: #262 → done (인라인 스크립트 재시작 테스트 완료), #258 → 미완료
|
||||
|
||||
## 핵심 발견
|
||||
|
||||
### 1. GB Observer 재시작 성공
|
||||
- Antigravity 풀 재시작 후 인라인 스크립트 정상 동작 확인
|
||||
- `[GB Observer] Bridge connected on port 34332` + `v2 Observer active`
|
||||
- **Allow Once / Allow This Conversation** 버튼 즉시 감지 + pending 생성 정상
|
||||
|
||||
### 2. Run 버튼 미감지 원인 (webview iframe 격리)
|
||||
- Trust/permission 버튼 → 워크벤치 외부 DOM → Observer가 감지 ✅
|
||||
- Run/Accept 버튼 → Antigravity 채팅 **webview iframe** 내부 DOM → `document.querySelector()` 접근 불가 ❌
|
||||
- **해결**: `latestToolCallStep` RPC 기반 즉시 감지로 대체
|
||||
|
||||
### 3. Accept all/Reject all 리뷰 바
|
||||
- 코드 변경 리뷰 바는 agent 패널(`findPanel()`) 밖 → `document.body` 전체 스캔으로 확장
|
||||
|
||||
### 4. Extension 재설치 안전성 확인
|
||||
- `setupApprovalObserver()`가 HTML 패치 + 체크섬 업데이트 **전자동** 수행
|
||||
- Extension VSIX만 설치하면 수동 패치 불필요
|
||||
- 단, 패치 후 **Antigravity 풀 재시작** 필수 (Electron 메인 프로세스 체크섬 캐시)
|
||||
|
||||
## 코드 변경
|
||||
- `extension.ts`: latestToolCallStep 즉시 감지, DOM scan 범위 확장, stall→100초 fallback
|
||||
- `.agents/workflows/start.md`: services.md 로딩 + Python 전체 경로
|
||||
- `.agents/workflows/services.md`: Python 전체 경로
|
||||
- `.agents/references/known-issues.md`: 5건 신규 추가
|
||||
|
||||
## 미완료
|
||||
- **latestToolCallStep protobuf 구조 확정** — 첫 실행 시 `[TOOL-STEP]` 덤프 로그로 status 필드 위치 확인 필요
|
||||
- **Accept all 리뷰 바 실제 감지 테스트** — body 스캔 후 올바르게 잡히는지 검증
|
||||
- **다음 세션**: AG 재시작 → tool call 발생 → 콘솔에서 구조 확인 → 필요 시 필드 매핑 수정
|
||||
@@ -605,7 +605,8 @@ function generateApprovalObserverScript(_port) {
|
||||
// ── 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'},
|
||||
@@ -613,7 +614,7 @@ function generateApprovalObserverScript(_port) {
|
||||
];
|
||||
|
||||
// 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){
|
||||
@@ -679,11 +680,18 @@ function generateApprovalObserverScript(_port) {
|
||||
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];
|
||||
@@ -736,6 +744,7 @@ function generateApprovalObserverScript(_port) {
|
||||
// Process ONE button per scan cycle (avoid flooding)
|
||||
return;
|
||||
}
|
||||
} // end searchRoots loop
|
||||
}
|
||||
|
||||
// ── Poll for Discord response ──
|
||||
@@ -903,6 +912,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
|
||||
@@ -975,16 +986,56 @@ function setupMonitor() {
|
||||
if (pollCount <= 10 || pollCount % 6 === 0 || delta > 0) {
|
||||
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)
|
||||
// DEBUG: dump session keys on first poll to find modTime field
|
||||
// ── 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}, lastModifiedTimestamp=${bestSession.lastModifiedTimestamp}, modifiedTime=${bestSession.modifiedTime}`);
|
||||
logToFile(`[DEBUG] lastModifiedTime=${bestSession.lastModifiedTime}`);
|
||||
if (toolStep)
|
||||
logToFile(`[DEBUG] toolStep present, stepIndex=${toolStep.stepIndex}`);
|
||||
}
|
||||
const currentModTime = bestSession.lastModifiedTime || bestSession.lastModifiedTimestamp || bestSession.modifiedTime || '';
|
||||
const modTimeChanged = currentModTime !== lastModTime;
|
||||
@@ -1013,17 +1064,17 @@ function setupMonitor() {
|
||||
lastModTime = currentModTime;
|
||||
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}"`;
|
||||
logToFile(`[STALL] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
|
||||
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 });
|
||||
}
|
||||
else if (consecutiveIdleCount === 6) {
|
||||
else if (consecutiveIdleCount === 20) {
|
||||
const reasons = [];
|
||||
if (!sawRunningAfterPending)
|
||||
reasons.push('needDelta>0');
|
||||
@@ -1269,6 +1320,49 @@ function filterEphemeral(text) {
|
||||
}
|
||||
return text;
|
||||
}
|
||||
/** Extract human-readable command from a tool call step's data. */
|
||||
function extractToolCommand(stepData) {
|
||||
// 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, sessionTitle, stepIndex) {
|
||||
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) {
|
||||
try {
|
||||
@@ -1286,6 +1380,8 @@ function writePendingApproval(data) {
|
||||
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`);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user