fix(bridge): approval flow robustness — pending cleanup, MERGE dedup, false positive filter, auto_resolve, 30min timeout

This commit is contained in:
2026-03-10 00:35:41 +09:00
parent 7fdefb0c63
commit 373c0f7ddc
8 changed files with 340 additions and 22 deletions

View File

@@ -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
View File

@@ -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}")

View File

@@ -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 = ""):

View File

@@ -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 | `` | 🔧 |

View 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 릴레이 미확인

View File

@@ -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

View File

@@ -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;
}
}