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이 보안 정책에 의해 차단됨
|
- **원인**: Electron 렌더러 프로세스에서 동기 XHR이 보안 정책에 의해 차단됨
|
||||||
- **해결**: `fetch()` + `AbortSignal.timeout(2000)` 비동기 방식으로 교체 (`tryPingAsync`)
|
- **해결**: `fetch()` + `AbortSignal.timeout(2000)` 비동기 방식으로 교체 (`tryPingAsync`)
|
||||||
- **주의**: `async/await` 사용 불가 (ES5 환경). `.then()` 체이닝으로 구현
|
- **주의**: `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:// 원본 캐시 서빙. 체크섬 수동 업데이트로 수정 | - | 🔧 |
|
| 14 | 17:01~17:38 | **근본 원인 발견**: product.json 체크섬 불일치 → vscode-file:// 원본 캐시 서빙. 체크섬 수동 업데이트로 수정 | - | 🔧 |
|
||||||
| 15 | 17:50~18:30 | **v0.3.5**: 포트 디스커버리 수정 (결정론적 포트 + 하드코딩), 인라인 스크립트 전환 (`<script src>` → `<script>inline</script>`), product.json 자동 체크섬 업데이트 | - | 🔧 |
|
| 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 풀 재시작 필요 발견 | - | 🔧 |
|
| 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) ──
|
// ── Button patterns to detect (order matters: first match wins per scan) ──
|
||||||
var PATS=[
|
var PATS=[
|
||||||
{re:/^Run$/i, type:'terminal_command'},
|
{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:/^Allow/i, type:'permission'},
|
||||||
{re:/^Approve/i, type:'agent_step'},
|
{re:/^Approve/i, type:'agent_step'},
|
||||||
{re:/^Continue$/i, type:'continue'},
|
{re:/^Continue$/i, type:'continue'},
|
||||||
@@ -613,7 +614,7 @@ function generateApprovalObserverScript(_port) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Reject button patterns for finding the counterpart
|
// 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) ──
|
// ── Stable button fingerprint (no getBoundingClientRect — scroll-safe) ──
|
||||||
function btnId(b,type){
|
function btnId(b,type){
|
||||||
@@ -679,11 +680,18 @@ function generateApprovalObserverScript(_port) {
|
|||||||
var now=Date.now();
|
var now=Date.now();
|
||||||
|
|
||||||
var panel=findPanel();
|
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 seen={}; // dedupe buttons across search roots
|
||||||
var allBtns=panel.querySelectorAll('button');
|
for(var r=0;r<searchRoots.length;r++){
|
||||||
if(!allBtns.length)return;
|
var allBtns=searchRoots[r].querySelectorAll('button');
|
||||||
|
if(!allBtns.length)continue;
|
||||||
|
|
||||||
for(var j=0;j<allBtns.length;j++){
|
for(var j=0;j<allBtns.length;j++){
|
||||||
var b=allBtns[j];
|
var b=allBtns[j];
|
||||||
@@ -736,6 +744,7 @@ function generateApprovalObserverScript(_port) {
|
|||||||
// Process ONE button per scan cycle (avoid flooding)
|
// Process ONE button per scan cycle (avoid flooding)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} // end searchRoots loop
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Poll for Discord response ──
|
// ── Poll for Discord response ──
|
||||||
@@ -903,6 +912,8 @@ function setupMonitor() {
|
|||||||
let lastKnownStepCount = 0;
|
let lastKnownStepCount = 0;
|
||||||
let lastNotifyStepIndex = -1;
|
let lastNotifyStepIndex = -1;
|
||||||
let lastTaskStepIndex = -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 lastPendingStepIndex = -1; // dedup: don't re-create pending for same step
|
||||||
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
|
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
|
||||||
let lastPendingTime = 0; // cooldown: minimum gap between pendings
|
let lastPendingTime = 0; // cooldown: minimum gap between pendings
|
||||||
@@ -975,16 +986,56 @@ function setupMonitor() {
|
|||||||
if (pollCount <= 10 || pollCount % 6 === 0 || delta > 0) {
|
if (pollCount <= 10 || pollCount % 6 === 0 || delta > 0) {
|
||||||
logToFile(`[POLL#${pollCount}] status=${statusStr} steps=${currentCount} delta=${delta}`);
|
logToFile(`[POLL#${pollCount}] status=${statusStr} steps=${currentCount} delta=${delta}`);
|
||||||
}
|
}
|
||||||
// ── Stall-based approval detection ──
|
// ── PRIMARY: Instant tool-call-based approval detection ──
|
||||||
// INSIGHT: Both thinking and approval show RUNNING+delta=0.
|
// latestToolCallStep contains the most recent tool call with its status.
|
||||||
// DIFFERENTIATOR: lastModifiedTime
|
// If status includes WAITING, the AI is waiting for user approval.
|
||||||
// - Thinking: lastModifiedTime KEEPS CHANGING (server actively processing)
|
const toolStep = bestSession.latestToolCallStep;
|
||||||
// - Approval wait: lastModifiedTime FROZEN (server idle, waiting for user)
|
if (toolStep && toolStep.stepIndex > lastToolStepIndex) {
|
||||||
// DEBUG: dump session keys on first poll to find modTime field
|
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) {
|
if (pollCount === 1) {
|
||||||
const keys = Object.keys(bestSession).filter(k => !['latestNotifyUserStep', 'latestTaskBoundaryStep', 'latestToolCallStep'].includes(k));
|
const keys = Object.keys(bestSession).filter(k => !['latestNotifyUserStep', 'latestTaskBoundaryStep', 'latestToolCallStep'].includes(k));
|
||||||
logToFile(`[DEBUG] session keys: ${keys.join(', ')}`);
|
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 currentModTime = bestSession.lastModifiedTime || bestSession.lastModifiedTimestamp || bestSession.modifiedTime || '';
|
||||||
const modTimeChanged = currentModTime !== lastModTime;
|
const modTimeChanged = currentModTime !== lastModTime;
|
||||||
@@ -1013,17 +1064,17 @@ function setupMonitor() {
|
|||||||
lastModTime = currentModTime;
|
lastModTime = currentModTime;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cooldownOk = (now - lastPendingTime) > 60_000;
|
const cooldownOk = (now - lastPendingTime) > 60_000;
|
||||||
if (consecutiveIdleCount >= 6 && sawRunningAfterPending && cooldownOk) {
|
if (consecutiveIdleCount >= 20 && sawRunningAfterPending && cooldownOk) {
|
||||||
// 6 polls × 5s = 30 seconds of FROZEN stall = approval waiting
|
// 20 polls × 5s = 100 seconds — fallback only
|
||||||
lastPendingStepIndex = currentCount;
|
lastPendingStepIndex = currentCount;
|
||||||
lastPendingTime = now;
|
lastPendingTime = now;
|
||||||
sawRunningAfterPending = false;
|
sawRunningAfterPending = false;
|
||||||
const command = `Stall at step ${currentCount}`;
|
const command = `Stall at step ${currentCount} (fallback)`;
|
||||||
const description = `승인 대기 감지 (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
|
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 });
|
writePendingApproval({ conversation_id: activeSessionId, command, description });
|
||||||
}
|
}
|
||||||
else if (consecutiveIdleCount === 6) {
|
else if (consecutiveIdleCount === 20) {
|
||||||
const reasons = [];
|
const reasons = [];
|
||||||
if (!sawRunningAfterPending)
|
if (!sawRunningAfterPending)
|
||||||
reasons.push('needDelta>0');
|
reasons.push('needDelta>0');
|
||||||
@@ -1269,6 +1320,49 @@ function filterEphemeral(text) {
|
|||||||
}
|
}
|
||||||
return 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. */
|
/** Write a pending approval file matching Bot's ApprovalRequest dataclass. */
|
||||||
function writePendingApproval(data) {
|
function writePendingApproval(data) {
|
||||||
try {
|
try {
|
||||||
@@ -1286,6 +1380,8 @@ function writePendingApproval(data) {
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
discord_message_id: 0,
|
discord_message_id: 0,
|
||||||
project_name: projectName,
|
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');
|
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
|
||||||
console.log(`Gravity Bridge: pending approval written → ${id}.json`);
|
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) ──
|
// ── Button patterns to detect (order matters: first match wins per scan) ──
|
||||||
var PATS=[
|
var PATS=[
|
||||||
{re:/^Run$/i, type:'terminal_command'},
|
{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:/^Allow/i, type:'permission'},
|
||||||
{re:/^Approve/i, type:'agent_step'},
|
{re:/^Approve/i, type:'agent_step'},
|
||||||
{re:/^Continue$/i, type:'continue'},
|
{re:/^Continue$/i, type:'continue'},
|
||||||
@@ -584,7 +585,7 @@ function generateApprovalObserverScript(_port: number): string {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Reject button patterns for finding the counterpart
|
// 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) ──
|
// ── Stable button fingerprint (no getBoundingClientRect — scroll-safe) ──
|
||||||
function btnId(b,type){
|
function btnId(b,type){
|
||||||
@@ -650,11 +651,18 @@ function generateApprovalObserverScript(_port: number): string {
|
|||||||
var now=Date.now();
|
var now=Date.now();
|
||||||
|
|
||||||
var panel=findPanel();
|
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 seen={}; // dedupe buttons across search roots
|
||||||
var allBtns=panel.querySelectorAll('button');
|
for(var r=0;r<searchRoots.length;r++){
|
||||||
if(!allBtns.length)return;
|
var allBtns=searchRoots[r].querySelectorAll('button');
|
||||||
|
if(!allBtns.length)continue;
|
||||||
|
|
||||||
for(var j=0;j<allBtns.length;j++){
|
for(var j=0;j<allBtns.length;j++){
|
||||||
var b=allBtns[j];
|
var b=allBtns[j];
|
||||||
@@ -707,6 +715,7 @@ function generateApprovalObserverScript(_port: number): string {
|
|||||||
// Process ONE button per scan cycle (avoid flooding)
|
// Process ONE button per scan cycle (avoid flooding)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} // end searchRoots loop
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Poll for Discord response ──
|
// ── Poll for Discord response ──
|
||||||
@@ -873,6 +882,8 @@ function setupMonitor() {
|
|||||||
let lastKnownStepCount = 0;
|
let lastKnownStepCount = 0;
|
||||||
let lastNotifyStepIndex = -1;
|
let lastNotifyStepIndex = -1;
|
||||||
let lastTaskStepIndex = -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 lastPendingStepIndex = -1; // dedup: don't re-create pending for same step
|
||||||
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
|
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
|
||||||
let lastPendingTime = 0; // cooldown: minimum gap between pendings
|
let lastPendingTime = 0; // cooldown: minimum gap between pendings
|
||||||
@@ -951,17 +962,62 @@ function setupMonitor() {
|
|||||||
logToFile(`[POLL#${pollCount}] status=${statusStr} steps=${currentCount} delta=${delta}`);
|
logToFile(`[POLL#${pollCount}] status=${statusStr} steps=${currentCount} delta=${delta}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stall-based approval detection ──
|
// ── PRIMARY: Instant tool-call-based approval detection ──
|
||||||
// INSIGHT: Both thinking and approval show RUNNING+delta=0.
|
// latestToolCallStep contains the most recent tool call with its status.
|
||||||
// DIFFERENTIATOR: lastModifiedTime
|
// If status includes WAITING, the AI is waiting for user approval.
|
||||||
// - Thinking: lastModifiedTime KEEPS CHANGING (server actively processing)
|
const toolStep = bestSession.latestToolCallStep;
|
||||||
// - Approval wait: lastModifiedTime FROZEN (server idle, waiting for user)
|
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) {
|
if (pollCount === 1) {
|
||||||
const keys = Object.keys(bestSession).filter(k => !['latestNotifyUserStep', 'latestTaskBoundaryStep', 'latestToolCallStep'].includes(k));
|
const keys = Object.keys(bestSession).filter(k => !['latestNotifyUserStep', 'latestTaskBoundaryStep', 'latestToolCallStep'].includes(k));
|
||||||
logToFile(`[DEBUG] session keys: ${keys.join(', ')}`);
|
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 || '';
|
const currentModTime = bestSession.lastModifiedTime || (bestSession as any).lastModifiedTimestamp || (bestSession as any).modifiedTime || '';
|
||||||
@@ -993,18 +1049,18 @@ function setupMonitor() {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cooldownOk = (now - lastPendingTime) > 60_000;
|
const cooldownOk = (now - lastPendingTime) > 60_000;
|
||||||
|
|
||||||
if (consecutiveIdleCount >= 6 && sawRunningAfterPending && cooldownOk) {
|
if (consecutiveIdleCount >= 20 && sawRunningAfterPending && cooldownOk) {
|
||||||
// 6 polls × 5s = 30 seconds of FROZEN stall = approval waiting
|
// 20 polls × 5s = 100 seconds — fallback only
|
||||||
lastPendingStepIndex = currentCount;
|
lastPendingStepIndex = currentCount;
|
||||||
lastPendingTime = now;
|
lastPendingTime = now;
|
||||||
sawRunningAfterPending = false;
|
sawRunningAfterPending = false;
|
||||||
|
|
||||||
const command = `Stall at step ${currentCount}`;
|
const command = `Stall at step ${currentCount} (fallback)`;
|
||||||
const description = `승인 대기 감지 (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
|
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 });
|
writePendingApproval({ conversation_id: activeSessionId, command, description });
|
||||||
} else if (consecutiveIdleCount === 6) {
|
} else if (consecutiveIdleCount === 20) {
|
||||||
const reasons = [];
|
const reasons = [];
|
||||||
if (!sawRunningAfterPending) reasons.push('needDelta>0');
|
if (!sawRunningAfterPending) reasons.push('needDelta>0');
|
||||||
if (!cooldownOk) reasons.push(`cooldown(${Math.round((60000 - (now - lastPendingTime)) / 1000)}s)`);
|
if (!cooldownOk) reasons.push(`cooldown(${Math.round((60000 - (now - lastPendingTime)) / 1000)}s)`);
|
||||||
@@ -1239,8 +1295,52 @@ function filterEphemeral(text: string): string | null {
|
|||||||
return text;
|
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. */
|
/** 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 {
|
try {
|
||||||
const pendingDir = path.join(bridgePath, 'pending');
|
const pendingDir = path.join(bridgePath, 'pending');
|
||||||
if (!fs.existsSync(pendingDir)) { fs.mkdirSync(pendingDir, { recursive: true }); }
|
if (!fs.existsSync(pendingDir)) { fs.mkdirSync(pendingDir, { recursive: true }); }
|
||||||
@@ -1254,6 +1354,8 @@ function writePendingApproval(data: { conversation_id: string; command: string;
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
discord_message_id: 0,
|
discord_message_id: 0,
|
||||||
project_name: projectName,
|
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');
|
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
|
||||||
console.log(`Gravity Bridge: pending approval written → ${id}.json`);
|
console.log(`Gravity Bridge: pending approval written → ${id}.json`);
|
||||||
|
|||||||
Reference in New Issue
Block a user