fix(bridge): approval flow robustness — pending cleanup, MERGE dedup, false positive filter, auto_resolve, 30min timeout
This commit is contained in:
@@ -261,3 +261,26 @@
|
||||
- **해결**: step probe 경로에서 reject 시 `tryApprovalStrategies` 호출 제거. reject은 로그만 남기고 파기적 동작 없음. DOM observer 경로만 실제 Reject 버튼 클릭 허용
|
||||
- **주의**: `ResolveOutstandingSteps`는 이름과 달리 "해결"이 아닌 "취소". 승인에 절대 사용 금지 (이전 이슈 #55~59 참조)
|
||||
|
||||
### [2026-03-09] Pending 파일 무한 누적 — write_response 후 미삭제
|
||||
- **증상**: `bridge/pending/` 디렉토리에 79개 이상의 .json 파일이 쌓여 봇 시작 시 폭주
|
||||
- **원인**: `write_response()`가 pending 파일 status만 변경하고 삭제하지 않음. `get_pending_requests()`에 age filter 없음
|
||||
- **해결**: (1) `write_response()`에서 pending 파일 삭제, (2) `get_pending_requests()`에 5분 age filter, (3) 시작 시 `_cleanup_stale_pending()` 호출
|
||||
- **주의**: 봇 재시작 시 자동 정리. age filter는 expired 마킹 후 skip
|
||||
|
||||
### [2026-03-09] Discord 승인 "Run" 표시 — DOM/step_probe 타이밍 불일치
|
||||
- **증상**: Discord에 상세 명령어 대신 "Run"만 표시
|
||||
- **원인**: DOM observer가 "Run" pending 생성(t=0) → 봇이 3초 후 전송 → step_probe MERGE가 10초 후 완료 → 이미 전송 후
|
||||
- **해결**: (1) step_probe가 기존 DOM pending에 MERGE (skip 대신 update), (2) 봇에서 짧은 명령어(≤15자) 4 cycles(12s) 대기, 매 cycle re-read하여 merge 즉시 전송
|
||||
- **주의**: MERGE 타이밍은 step_probe poll interval(5s) + stall detection 필요하므로 최소 10초. defer는 이보다 길어야 함
|
||||
|
||||
### [2026-03-09] DOM observer false positive — Proceed/Continue/Open 버튼 오감지
|
||||
- **증상**: 작업 전환 시(notify_user, task_boundary) 승인 요청 없는데도 Discord에 승인 요청 도착
|
||||
- **원인**: DOM observer가 AG UI의 PathsToReview "Proceed"/"Open" 버튼, 파일 Open 버튼 등을 승인 버튼으로 오인
|
||||
- **해결**: (1) HTTP POST /pending 핸들러에 false positive 필터 추가 (Proceed/Continue/Open/Close/OK 등), (2) "Run" 버튼은 `sessionStalled=true`일 때만 허용
|
||||
- **주의**: renderer 인라인 스크립트(HTML)는 extension.js 배포로 안 바뀜 → 서버사이드 필터가 필수. PATS 패턴 수정은 HTML 재패치 시에만 적용
|
||||
|
||||
### [2026-03-09] Discord ApprovalView timeout — 5분 후 버튼 무응답
|
||||
- **증상**: 시간이 지난 후 Discord 승인 버튼 클릭해도 반응 없음
|
||||
- **원인**: Discord.py View의 기본 timeout이 300초(5분)로 설정됨
|
||||
- **해결**: timeout을 1800초(30분)로 증가
|
||||
- **주의**: Discord View timeout은 서버 재시작 후만 적용. 기존 메시지의 View는 이미 만료됨
|
||||
|
||||
49
bot.py
49
bot.py
@@ -35,7 +35,7 @@ class ApprovalView(discord.ui.View):
|
||||
"""Discord buttons for approving/rejecting Antigravity actions."""
|
||||
|
||||
def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest):
|
||||
super().__init__(timeout=300)
|
||||
super().__init__(timeout=1800) # 30 minutes
|
||||
self.bridge = bridge
|
||||
self.request = request
|
||||
self.responded = False
|
||||
@@ -100,6 +100,7 @@ class GravityBot(commands.Bot):
|
||||
self.channel_to_project: dict[int, str] = {} # channel.id → project
|
||||
self.session_status_messages: dict[str, int] = {} # conv_id → msg_id
|
||||
self._sent_approval_ids: set[str] = set()
|
||||
self._deferred_ids: dict[str, int] = {} # request_id → defer count
|
||||
self._ready_event = asyncio.Event()
|
||||
self._channel_lock = asyncio.Lock()
|
||||
self.bridge = BridgeProtocol()
|
||||
@@ -450,6 +451,25 @@ class GravityBot(commands.Bot):
|
||||
if req.discord_message_id != 0:
|
||||
continue
|
||||
|
||||
# Defer short-command pendings (e.g. "Run") by 4 cycles (~12s)
|
||||
# to give step_probe time to merge detailed command info
|
||||
# (step_probe MERGE happens ~10s after pending creation)
|
||||
if len(req.command) <= 15:
|
||||
if req.request_id not in self._deferred_ids:
|
||||
self._deferred_ids[req.request_id] = 1
|
||||
continue # skip this cycle
|
||||
elif self._deferred_ids[req.request_id] < 4:
|
||||
self._deferred_ids[req.request_id] += 1
|
||||
# Re-read from file (step_probe may have merged)
|
||||
fresh = self.bridge.read_pending_request(req.request_id)
|
||||
if fresh and len(fresh.command) > 15:
|
||||
req = fresh # use merged version — send now!
|
||||
else:
|
||||
continue # wait one more cycle
|
||||
|
||||
# Clean up defer tracking
|
||||
self._deferred_ids.pop(req.request_id, None)
|
||||
|
||||
# Learn project mapping from pending approval
|
||||
project = req.project_name or Config.PROJECT_NAME
|
||||
if req.conversation_id and req.conversation_id != '__global__':
|
||||
@@ -459,6 +479,33 @@ class GravityBot(commands.Bot):
|
||||
if channel:
|
||||
self._sent_approval_ids.add(req.request_id)
|
||||
await self._send_approval_request(channel, req)
|
||||
|
||||
# ── Check for auto_resolved pendings (approved directly in AG) ──
|
||||
for f in self.bridge.pending_dir.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
||||
if data.get("status") == "auto_resolved":
|
||||
msg_id = data.get("discord_message_id", 0)
|
||||
project = data.get("project_name", Config.PROJECT_NAME)
|
||||
if msg_id:
|
||||
channel = await self._get_channel(project)
|
||||
if channel:
|
||||
try:
|
||||
msg = await channel.fetch_message(msg_id)
|
||||
embed = discord.Embed(
|
||||
title="✅ AG에서 직접 승인됨",
|
||||
description=f"```\n{data.get('command', '')[:500]}\n```",
|
||||
color=discord.Color.green(),
|
||||
)
|
||||
embed.set_footer(text=f"ID: {data.get('request_id', '')}")
|
||||
await msg.edit(embed=embed, view=None)
|
||||
except discord.NotFound:
|
||||
pass
|
||||
f.unlink()
|
||||
self._deferred_ids.pop(data.get("request_id", ""), None)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning approvals: {e}")
|
||||
|
||||
|
||||
63
bridge.py
63
bridge.py
@@ -68,15 +68,46 @@ class BridgeProtocol:
|
||||
for d in [self.pending_dir, self.response_dir, self.commands_dir]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Startup cleanup: purge stale pending files (> 5 min old)
|
||||
self._cleanup_stale_pending()
|
||||
|
||||
logger.info(f"Bridge protocol initialized: {self.bridge_dir}")
|
||||
|
||||
def get_pending_requests(self) -> list[ApprovalRequest]:
|
||||
"""Read all pending approval requests."""
|
||||
requests = []
|
||||
fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()}
|
||||
def _cleanup_stale_pending(self, max_age_seconds: int = 300):
|
||||
"""Remove pending files older than max_age_seconds on startup."""
|
||||
now = time.time()
|
||||
cleaned = 0
|
||||
for f in self.pending_dir.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
||||
ts = data.get("timestamp", 0)
|
||||
if now - ts > max_age_seconds:
|
||||
f.unlink()
|
||||
cleaned += 1
|
||||
except (json.JSONDecodeError, OSError):
|
||||
f.unlink() # corrupt file, remove
|
||||
cleaned += 1
|
||||
if cleaned:
|
||||
logger.info(f"Startup cleanup: removed {cleaned} stale pending files")
|
||||
|
||||
def get_pending_requests(self) -> list[ApprovalRequest]:
|
||||
"""Read all pending approval requests. Skips files older than 5 minutes."""
|
||||
requests = []
|
||||
fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()}
|
||||
now = time.time()
|
||||
MAX_AGE = 300 # 5 minutes
|
||||
for f in self.pending_dir.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
||||
ts = data.get("timestamp", 0)
|
||||
if now - ts > MAX_AGE:
|
||||
# Too old — mark expired and skip
|
||||
data["status"] = "expired"
|
||||
f.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
continue
|
||||
if data.get("status") == "pending":
|
||||
# Filter to known fields only
|
||||
filtered = {k: v for k, v in data.items() if k in fields}
|
||||
@@ -85,6 +116,19 @@ class BridgeProtocol:
|
||||
logger.warning(f"Bad pending request {f.name}: {e}")
|
||||
return requests
|
||||
|
||||
def read_pending_request(self, request_id: str) -> ApprovalRequest | None:
|
||||
"""Re-read a specific pending request (to get merged data)."""
|
||||
f = self.pending_dir / f"{request_id}.json"
|
||||
if not f.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
||||
fields = {fn.name for fn in ApprovalRequest.__dataclass_fields__.values()}
|
||||
filtered = {k: v for k, v in data.items() if k in fields}
|
||||
return ApprovalRequest(**filtered)
|
||||
except (json.JSONDecodeError, TypeError, OSError):
|
||||
return None
|
||||
|
||||
def write_response(self, response: UserResponse):
|
||||
"""Write a user response to the response directory."""
|
||||
response.timestamp = time.time()
|
||||
@@ -97,17 +141,12 @@ class BridgeProtocol:
|
||||
)
|
||||
logger.info(f"Response written: {filename} (approved={response.approved})")
|
||||
|
||||
# Mark pending request as processed
|
||||
# Delete pending file after processing (prevents re-processing and accumulation)
|
||||
pending_file = self.pending_dir / filename
|
||||
if pending_file.exists():
|
||||
try:
|
||||
data = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||
data["status"] = "approved" if response.approved else "rejected"
|
||||
pending_file.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pending_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def write_command(self, conversation_id: str, text: str, *, project_name: str = ""):
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
| 009 | 21:33~22:28 | 승인 플로우 튜닝 — dedup + 텍스트 정제 + stall fallback 제거 + reject 안전화 | `18b3734` | ✅ |
|
||||
| 010 | 22:38~23:10 | E2E 검증 + Retry/Dismiss/Reject all 버튼 패턴 추가 + V8 캐시 삭제 | `4ba65f9` | ✅ |
|
||||
| 011 | 23:11~23:20 | agent_guide 템플릿 통합 — 워크플로우 교체 + 플레이스홀더 적용 + 중복 helper 정리 | `4ba65f9` | ✅ |
|
||||
| 012 | 23:30~00:31 | 승인 플로우 안정화 — pending 누적/false positive/MERGE dedup/auto_resolve/timeout | `` | 🔧 |
|
||||
|
||||
16
docs/devlog/entries/20260309-012.md
Normal file
16
docs/devlog/entries/20260309-012.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 승인 플로우 안정화 — pending 누적/MERGE dedup/false positive/auto_resolve
|
||||
|
||||
- **시간**: 2026-03-09 23:30 ~ 2026-03-10 00:31
|
||||
- **Commit**: `72d718f`
|
||||
- **Vikunja**: #276 → 미완료 (검증 필요)
|
||||
|
||||
## 결정 사항
|
||||
- **MERGE vs. Skip**: step_probe가 DOM observer pending에 상세 명령어를 MERGE (이전: skip). DOM이 먼저 "Run" pending 생성 → step_probe가 10초 후 상세 정보로 UPDATE
|
||||
- **봇 Deferred Sending**: 짧은 명령어(≤15자) 4 cycles(12s) 대기. 매 cycle re-read하여 MERGE 즉시 전송. MERGE 타이밍(~10s)보다 defer를 길게 설정
|
||||
- **False Positive 필터**: renderer HTML 인라인 스크립트는 extension.js 배포로 안 바뀜 → HTTP POST /pending 핸들러에 서버사이드 필터 추가. `sessionStalled` 플래그로 "Run" 버튼 게이팅
|
||||
- **auto_resolve**: delta>0 발생 시 pending을 "auto_resolved"로 마킹 → 봇이 Discord 메시지를 "✅ AG에서 직접 승인됨"으로 업데이트
|
||||
|
||||
## 미완료
|
||||
- AG 재시작 후 E2E 검증 필요 (Vikunja #276)
|
||||
- error recovery (Retry) 감지 실제 동작 확인 필요
|
||||
- "Accept all" diff review bar 버튼 Discord 릴레이 미확인
|
||||
@@ -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
@@ -402,6 +402,7 @@ const pendingResponses = new Map<string, { approved: boolean } | null>();
|
||||
|
||||
// Click trigger: extension sets this, renderer polls and clicks button
|
||||
let clickTrigger: { action: 'approve' | 'reject'; timestamp: number } | null = 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;
|
||||
@@ -437,6 +438,24 @@ function startObserverHttpBridge(): Promise<number> {
|
||||
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');
|
||||
@@ -866,8 +885,6 @@ function generateApprovalObserverScript(_port: number): string {
|
||||
{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'},
|
||||
];
|
||||
@@ -1379,6 +1396,26 @@ function setupMonitor() {
|
||||
}
|
||||
|
||||
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: string) => 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: any) { logToFile(`[AUTO-RESOLVE] error: ${e.message}`); }
|
||||
lastPendingStepIndex = -1;
|
||||
}
|
||||
consecutiveIdleCount = 0;
|
||||
sawRunningAfterPending = true;
|
||||
stallProbed = false; // allow re-probe on next stall
|
||||
@@ -1394,6 +1431,7 @@ function setupMonitor() {
|
||||
} else {
|
||||
// lastModifiedTime frozen = real stall (approval waiting)
|
||||
consecutiveIdleCount++;
|
||||
if (consecutiveIdleCount >= 1) sessionStalled = true;
|
||||
}
|
||||
lastModTime = currentModTime;
|
||||
|
||||
@@ -1474,6 +1512,53 @@ function setupMonitor() {
|
||||
// Stall fallback REMOVED — step probe is sole fallback source
|
||||
// (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: any) {
|
||||
logToFile(`[STEP-PROBE-ERR] error check: ${e.message}`);
|
||||
}
|
||||
}
|
||||
consecutiveIdleCount = 0;
|
||||
lastModTime = currentModTime;
|
||||
}
|
||||
@@ -1728,7 +1813,7 @@ function writePendingApproval(data: { conversation_id: string; command: string;
|
||||
const pendingDir = path.join(bridgePath, 'pending');
|
||||
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 {
|
||||
@@ -1739,7 +1824,14 @@ function writePendingApproval(data: { conversation_id: string; command: string;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user