@adhdev/daemon-core 0.9.82-rc.465 → 0.9.82-rc.467
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +149 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +149 -10
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-pending.d.ts +8 -0
- package/dist/mesh/mesh-runtime-store.d.ts +26 -0
- package/dist/providers/cli-provider-instance.d.ts +40 -0
- package/package.json +3 -3
- package/src/mesh/mesh-event-forwarding.ts +16 -1
- package/src/mesh/mesh-events-pending.ts +36 -0
- package/src/mesh/mesh-ledger.ts +10 -0
- package/src/mesh/mesh-runtime-store.ts +53 -0
- package/src/providers/cli-provider-instance.ts +116 -3
|
@@ -115,6 +115,14 @@ export declare function readRefineJobId(event: {
|
|
|
115
115
|
} | Record<string, unknown>): string;
|
|
116
116
|
export declare function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent): string;
|
|
117
117
|
export declare function hasPendingCoordinatorEventDuplicate(event: PendingMeshCoordinatorEvent): boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Retention sweep for the SQLite mesh_pending_events inbox. Deletes long-drained
|
|
120
|
+
* rows (past the idempotency-useful window) and long-orphaned undrained rows (a
|
|
121
|
+
* coordinator identity that never returned). Best-effort and idempotent: a store
|
|
122
|
+
* failure or an empty table is a cheap no-op. Called from the periodic mesh-event
|
|
123
|
+
* maintenance sweep. Returns the number of rows pruned (0 when nothing to do).
|
|
124
|
+
*/
|
|
125
|
+
export declare function prunePendingMeshCoordinatorEventsRetention(): number;
|
|
118
126
|
/**
|
|
119
127
|
* Stamp the v2 protocol envelope onto a pending event at emit time (B2a).
|
|
120
128
|
*
|
|
@@ -490,4 +490,30 @@ export declare class MeshRuntimeStore {
|
|
|
490
490
|
* for the same task could be re-queued later. Returns the number of rows deleted.
|
|
491
491
|
*/
|
|
492
492
|
deletePendingEventsById(ids: ReadonlyArray<string>): number;
|
|
493
|
+
/**
|
|
494
|
+
* Retention prune for mesh_pending_events. This table has no lifecycle GC of its
|
|
495
|
+
* own: a drained row is soft-marked (drained=1) and RETAINED — deliberately, so
|
|
496
|
+
* drainedEventIdsForMesh() has a durable v2-eventId dedup baseline — and an
|
|
497
|
+
* undrained row queued for a coordinator that never returned (a dead/evicted
|
|
498
|
+
* coordinator identity) stays drained=0 forever. Both accumulate without bound
|
|
499
|
+
* (observed: tens of thousands of rows, mostly stale). This is the missing
|
|
500
|
+
* retention step. Two independent windows:
|
|
501
|
+
*
|
|
502
|
+
* - drained rows older than `drainedOlderThanMs`: the coordinator consumed them
|
|
503
|
+
* long ago; the only thing they still back is the eventId re-delivery guard,
|
|
504
|
+
* which is only meaningful for the recent past (a re-delivery of a week-old
|
|
505
|
+
* event cannot occur — its producer session is long gone). Safe to delete.
|
|
506
|
+
* - UNDRAINED rows older than `undrainedOlderThanMs` (a much wider window):
|
|
507
|
+
* these are orphaned events for a coordinator identity that never drained
|
|
508
|
+
* them. Kept wide so a genuinely-offline-but-returning coordinator still
|
|
509
|
+
* receives its backlog; only genuinely unrecoverable orphans are swept.
|
|
510
|
+
*
|
|
511
|
+
* Both windows key off `queued_at` (always present) — `drained_at` can be NULL on
|
|
512
|
+
* legacy rows. Returns the number of rows deleted. Best-effort / idempotent:
|
|
513
|
+
* running it repeatedly with nothing to prune is a cheap no-op.
|
|
514
|
+
*/
|
|
515
|
+
prunePendingEvents(opts: {
|
|
516
|
+
drainedOlderThanMs: number;
|
|
517
|
+
undrainedOlderThanMs: number;
|
|
518
|
+
}): number;
|
|
493
519
|
}
|
|
@@ -44,6 +44,33 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
44
44
|
* from scratch rather than firing on a stale timestamp.
|
|
45
45
|
*/
|
|
46
46
|
private static readonly AUTO_APPROVE_GATE_HYSTERESIS_MS;
|
|
47
|
+
/**
|
|
48
|
+
* AUTOAPPROVE-FLAP-RECUR (Fix B): extended busy-side continuity window for a
|
|
49
|
+
* DELEGATED-WORKER auto-approve episode that is genuinely still cycling.
|
|
50
|
+
*
|
|
51
|
+
* The default AUTO_APPROVE_GATE_HYSTERESIS_MS (1500) absorbs a *momentary*
|
|
52
|
+
* `generating` blip. But a delegated worker running a Bash approval observed
|
|
53
|
+
* the FSM cycle the FULL state waiting_approval → busy → waiting_approval on a
|
|
54
|
+
* 2–5s period (the button set scrolls in/out AND the modal question repaints,
|
|
55
|
+
* so the adapter genuinely reports status=generating for whole seconds between
|
|
56
|
+
* approval frames). Each busy phase outran the 1500ms hysteresis, so the
|
|
57
|
+
* settle clock was WIPED (the genuine-resolution branch), the 600ms settle
|
|
58
|
+
* window never accumulated across the flap, resolveModal never fired
|
|
59
|
+
* (resolveModal count 0), and the mask-stall clock instead tripped at 4500ms →
|
|
60
|
+
* coordinator nudge → the flap the coordinator observed.
|
|
61
|
+
*
|
|
62
|
+
* A genuine resolution and a flap both start with a busy phase; they diverge
|
|
63
|
+
* only in whether waiting_approval RETURNS. So we cannot simply lengthen the
|
|
64
|
+
* blanket hysteresis (that would make every real resolution hold the gate
|
|
65
|
+
* open for seconds). Instead this longer window applies ONLY while an active
|
|
66
|
+
* mask episode is alive (autoApproveMaskSince > 0) AND the session is a
|
|
67
|
+
* delegated worker — i.e. exactly the never-resolving-flap case. A foreground
|
|
68
|
+
* / attended session keeps the tight 1500ms window unchanged. The mask-stall
|
|
69
|
+
* bound below still caps the episode, so a worker whose approval truly never
|
|
70
|
+
* returns is surfaced to the coordinator within AUTO_APPROVE_MASK_STALL_MS
|
|
71
|
+
* rather than held forever.
|
|
72
|
+
*/
|
|
73
|
+
private static readonly AUTO_APPROVE_FLAP_CONTINUITY_MS;
|
|
47
74
|
/**
|
|
48
75
|
* STATUS-MISMATCH: upper bound on how long the auto-approve→`generating` SURFACE
|
|
49
76
|
* mask may hide a worker's `waiting_approval` (status + activeModal) before we give
|
|
@@ -83,6 +110,7 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
83
110
|
private pendingAutoApprovalSince;
|
|
84
111
|
private autoApproveSettleTimer;
|
|
85
112
|
private autoApproveInactiveSince;
|
|
113
|
+
private autoApproveLastModalSeenAt;
|
|
86
114
|
private autoApproveMaskSince;
|
|
87
115
|
private stalledApprovalNudgeEpisode;
|
|
88
116
|
private readonly manualAttendance;
|
|
@@ -272,6 +300,18 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
272
300
|
private approvalResolutionFinalizationBlock;
|
|
273
301
|
private scheduleCompletedDebounceFlush;
|
|
274
302
|
private isMeshWorkerSession;
|
|
303
|
+
/**
|
|
304
|
+
* AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
|
|
305
|
+
* persist before the in-progress settle gate is torn down. For a delegated
|
|
306
|
+
* worker whose auto-approve episode is genuinely still cycling (mask clock
|
|
307
|
+
* alive), the FSM's full waiting_approval → busy → waiting_approval flap runs
|
|
308
|
+
* on a multi-second period, so the settle continuity window is extended to
|
|
309
|
+
* AUTO_APPROVE_FLAP_CONTINUITY_MS to bridge it (still bounded, and still capped
|
|
310
|
+
* by AUTO_APPROVE_MASK_STALL_MS). Every other case — foreground/attended
|
|
311
|
+
* session, or no active mask episode — keeps the tight default hysteresis so a
|
|
312
|
+
* genuine resolution frees the gate promptly.
|
|
313
|
+
*/
|
|
314
|
+
private autoApproveContinuityWindowMs;
|
|
275
315
|
private isAutonomousMeshSession;
|
|
276
316
|
/**
|
|
277
317
|
* ARCH-REFACTOR R1: the taskId to attribute the CURRENTLY-completing turn to.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.467",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"author": "vilmire",
|
|
48
48
|
"license": "AGPL-3.0-or-later",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "0.9.82-rc.467",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.467",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
@@ -7,7 +7,7 @@ import type { SessionRecoveryContext } from './mesh-ledger.js';
|
|
|
7
7
|
import { updateSessionTaskStatus, enqueueTask, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, getActiveDirectDispatches, hasPendingDependents, getQueue } from './mesh-work-queue.js';
|
|
8
8
|
import { markSessionDeliveriesTerminal, updateSessionDeliveryStatus, recordCompletionConflict } from './mesh-delivery-policy.js';
|
|
9
9
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
10
|
-
import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, readV2EnvelopeFromWire, type PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
10
|
+
import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, prunePendingMeshCoordinatorEventsRetention, readV2EnvelopeFromWire, type PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
11
11
|
import type { ProviderInstance } from '../providers/provider-instance.js';
|
|
12
12
|
import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent, isUnroutableDelegateRejection } from './mesh-routing.js';
|
|
13
13
|
import { resolveMeshHostStatus } from './mesh-host-ownership.js';
|
|
@@ -178,10 +178,25 @@ export function __resetMeshWorkspaceCacheForTests(): void {
|
|
|
178
178
|
meshByWorkspaceCache.clear();
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// Throttle the pending-event retention DELETE so it runs at most hourly — the
|
|
182
|
+
// remote-idle sweep below fires on every completion/idle transition, but a table
|
|
183
|
+
// scan + DELETE should not. In-process timestamp; a restart re-arms it, which only
|
|
184
|
+
// means one prune shortly after boot (harmless, idempotent).
|
|
185
|
+
let lastPendingEventsPruneAt = 0;
|
|
186
|
+
const PENDING_EVENTS_PRUNE_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
187
|
+
|
|
181
188
|
function sweepExpiredRemoteIdleSessions(): void {
|
|
182
189
|
try {
|
|
183
190
|
MeshRuntimeStore.getInstance().pruneExpiredRemoteIdleSessions();
|
|
184
191
|
} catch { /* best-effort */ }
|
|
192
|
+
// Piggyback the pending-event retention prune on the same periodic sweep, but
|
|
193
|
+
// hourly — this is the maintenance hook that keeps mesh_pending_events from
|
|
194
|
+
// accumulating stale drained/orphaned rows without bound.
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
if (now - lastPendingEventsPruneAt >= PENDING_EVENTS_PRUNE_INTERVAL_MS) {
|
|
197
|
+
lastPendingEventsPruneAt = now;
|
|
198
|
+
prunePendingMeshCoordinatorEventsRetention();
|
|
199
|
+
}
|
|
185
200
|
}
|
|
186
201
|
|
|
187
202
|
const INTENTIONAL_CLEANUP_STOP_SUPPRESSION_MS = 30 * 60 * 1000;
|
|
@@ -675,6 +675,42 @@ function reconcilePendingMeshCoordinatorEvents(meshId: string, events: PendingMe
|
|
|
675
675
|
const MAX_PENDING_EVENTS_BYTES = 100 * 1024; // 100 KB — keep the pending file small
|
|
676
676
|
const MAX_PENDING_EVENTS_KEEP = 50; // keep the last 50 events when trimming
|
|
677
677
|
|
|
678
|
+
// ─── SQLite pending-event retention ─────────────────────────────────────────
|
|
679
|
+
// mesh_pending_events had no lifecycle GC: drained rows are retained forever (the
|
|
680
|
+
// durable v2-eventId dedup baseline drainedEventIdsForMesh reads them), and an
|
|
681
|
+
// undrained row for a coordinator identity that never returns stays queued forever.
|
|
682
|
+
// Both accumulate without bound. These windows bound that growth while preserving
|
|
683
|
+
// the two things the rows exist for — recent-re-delivery idempotency and delivery
|
|
684
|
+
// to a returning coordinator. A drained event older than the drained window cannot
|
|
685
|
+
// be re-delivered (its producer session is long gone), so keeping it buys nothing;
|
|
686
|
+
// an undrained event is kept far longer so a genuinely-offline coordinator's backlog
|
|
687
|
+
// survives, and only unrecoverable orphans are swept.
|
|
688
|
+
const PENDING_EVENTS_DRAINED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
689
|
+
const PENDING_EVENTS_UNDRAINED_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Retention sweep for the SQLite mesh_pending_events inbox. Deletes long-drained
|
|
693
|
+
* rows (past the idempotency-useful window) and long-orphaned undrained rows (a
|
|
694
|
+
* coordinator identity that never returned). Best-effort and idempotent: a store
|
|
695
|
+
* failure or an empty table is a cheap no-op. Called from the periodic mesh-event
|
|
696
|
+
* maintenance sweep. Returns the number of rows pruned (0 when nothing to do).
|
|
697
|
+
*/
|
|
698
|
+
export function prunePendingMeshCoordinatorEventsRetention(): number {
|
|
699
|
+
try {
|
|
700
|
+
const removed = MeshRuntimeStore.getInstance().prunePendingEvents({
|
|
701
|
+
drainedOlderThanMs: PENDING_EVENTS_DRAINED_RETENTION_MS,
|
|
702
|
+
undrainedOlderThanMs: PENDING_EVENTS_UNDRAINED_RETENTION_MS,
|
|
703
|
+
});
|
|
704
|
+
if (removed > 0) {
|
|
705
|
+
LOG.info('MeshEvents', `Pruned ${removed} stale pending-event row(s) (drained >7d / undrained >30d)`);
|
|
706
|
+
}
|
|
707
|
+
return removed;
|
|
708
|
+
} catch (e: any) {
|
|
709
|
+
LOG.warn('MeshEvents', `Pending-event retention prune failed: ${e?.message || e}`);
|
|
710
|
+
return 0;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
678
714
|
function trimPendingEventsIfNeeded(path: string): void {
|
|
679
715
|
try {
|
|
680
716
|
if (!existsSync(path)) return;
|
package/src/mesh/mesh-ledger.ts
CHANGED
|
@@ -255,11 +255,21 @@ const RECENT_FAILURE_WINDOW_MS = 30 * 60 * 1000; // 30 minutes
|
|
|
255
255
|
|
|
256
256
|
// Kinds that accumulate indefinitely and are safe to archive after ARCHIVE_TERMINAL_OLDER_THAN_MS.
|
|
257
257
|
// Non-terminal kinds (dispatched, sessions, nodes, checkpoints) are always kept in the active file.
|
|
258
|
+
//
|
|
259
|
+
// session_auto_launch is telemetry, NOT audit: it records the queue-assignment
|
|
260
|
+
// decision on each tick (mostly phase:'skipped' — nothing was launched). No reader
|
|
261
|
+
// consults it back from the ledger (unlike task_dispatched, which getSessionRecoveryContext
|
|
262
|
+
// scans and which therefore MUST stay non-archivable). It is the single largest
|
|
263
|
+
// avoidable ledger consumer after node/dispatch history, so archiving it after the
|
|
264
|
+
// 7-day window is the volume fix — recent entries still surface for diagnosis (and the
|
|
265
|
+
// per-tick dedup keeps live volume bounded), old ones move to the archive JSONL and out
|
|
266
|
+
// of the runtime store. This preserves observability while removing the standing bulk.
|
|
258
267
|
const ARCHIVABLE_KINDS: ReadonlySet<MeshLedgerKind> = new Set([
|
|
259
268
|
'task_completed',
|
|
260
269
|
'task_failed',
|
|
261
270
|
'task_stalled',
|
|
262
271
|
'recovery_attempted',
|
|
272
|
+
'session_auto_launch',
|
|
263
273
|
] as MeshLedgerKind[]);
|
|
264
274
|
const DEFAULT_LEDGER_SLICE_LIMIT = 100;
|
|
265
275
|
export const MAX_LEDGER_SLICE_LIMIT = 500;
|
|
@@ -1551,6 +1551,18 @@ export class MeshRuntimeStore {
|
|
|
1551
1551
|
providerType?: string | null;
|
|
1552
1552
|
payload?: unknown;
|
|
1553
1553
|
}): void {
|
|
1554
|
+
// Ledger `kind` is a mandatory schema invariant (mesh_event_ledger.kind is
|
|
1555
|
+
// NOT NULL; every MeshLedgerKind is a non-empty tag). A blank kind would be a
|
|
1556
|
+
// structurally-broken entry — reject it here rather than write an unqueryable
|
|
1557
|
+
// row. NOTE: pending-event JSONL files (`*.pending-events.jsonl`) are a
|
|
1558
|
+
// SEPARATE shape that intentionally has NO `kind` field (they key off `.event`);
|
|
1559
|
+
// a generic audit that scans the whole ledger DIRECTORY and reads `.kind` off
|
|
1560
|
+
// those rows sees "kind=None", which is an artifact of mixing the two files, not
|
|
1561
|
+
// a ledger defect. This guard makes the ledger-side invariant explicit.
|
|
1562
|
+
if (!entry.kind || !String(entry.kind).trim()) {
|
|
1563
|
+
LOG.warn('MeshRuntimeStore', `Refusing to append ledger entry with empty kind for mesh ${entry.meshId} (id ${entry.id})`);
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1554
1566
|
this.db.prepare(
|
|
1555
1567
|
`INSERT OR IGNORE INTO mesh_event_ledger
|
|
1556
1568
|
(id, mesh_id, timestamp, kind, node_id, session_id, provider_type, payload)
|
|
@@ -1693,6 +1705,11 @@ export class MeshRuntimeStore {
|
|
|
1693
1705
|
);
|
|
1694
1706
|
this.db.transaction(() => {
|
|
1695
1707
|
for (const e of entries) {
|
|
1708
|
+
// Skip structurally-broken entries with a blank kind (see appendLedgerEntry):
|
|
1709
|
+
// mesh_event_ledger.kind is NOT NULL and every kind is a non-empty tag, so an
|
|
1710
|
+
// empty-kind row is unqueryable noise. Mirrors readLedgerFile's `entry.id && entry.kind`
|
|
1711
|
+
// JSONL guard, keeping the import path from re-introducing what the read path filters.
|
|
1712
|
+
if (!e.kind || !String(e.kind).trim()) continue;
|
|
1696
1713
|
const result = stmt.run(
|
|
1697
1714
|
e.id, e.meshId, e.timestamp, e.kind,
|
|
1698
1715
|
e.nodeId ?? null, e.sessionId ?? null, e.providerType ?? null,
|
|
@@ -2053,4 +2070,40 @@ export class MeshRuntimeStore {
|
|
|
2053
2070
|
`DELETE FROM mesh_pending_events WHERE id IN (${idList.map(() => '?').join(',')})`
|
|
2054
2071
|
).run(...idList).changes;
|
|
2055
2072
|
}
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* Retention prune for mesh_pending_events. This table has no lifecycle GC of its
|
|
2076
|
+
* own: a drained row is soft-marked (drained=1) and RETAINED — deliberately, so
|
|
2077
|
+
* drainedEventIdsForMesh() has a durable v2-eventId dedup baseline — and an
|
|
2078
|
+
* undrained row queued for a coordinator that never returned (a dead/evicted
|
|
2079
|
+
* coordinator identity) stays drained=0 forever. Both accumulate without bound
|
|
2080
|
+
* (observed: tens of thousands of rows, mostly stale). This is the missing
|
|
2081
|
+
* retention step. Two independent windows:
|
|
2082
|
+
*
|
|
2083
|
+
* - drained rows older than `drainedOlderThanMs`: the coordinator consumed them
|
|
2084
|
+
* long ago; the only thing they still back is the eventId re-delivery guard,
|
|
2085
|
+
* which is only meaningful for the recent past (a re-delivery of a week-old
|
|
2086
|
+
* event cannot occur — its producer session is long gone). Safe to delete.
|
|
2087
|
+
* - UNDRAINED rows older than `undrainedOlderThanMs` (a much wider window):
|
|
2088
|
+
* these are orphaned events for a coordinator identity that never drained
|
|
2089
|
+
* them. Kept wide so a genuinely-offline-but-returning coordinator still
|
|
2090
|
+
* receives its backlog; only genuinely unrecoverable orphans are swept.
|
|
2091
|
+
*
|
|
2092
|
+
* Both windows key off `queued_at` (always present) — `drained_at` can be NULL on
|
|
2093
|
+
* legacy rows. Returns the number of rows deleted. Best-effort / idempotent:
|
|
2094
|
+
* running it repeatedly with nothing to prune is a cheap no-op.
|
|
2095
|
+
*/
|
|
2096
|
+
prunePendingEvents(opts: { drainedOlderThanMs: number; undrainedOlderThanMs: number }): number {
|
|
2097
|
+
const now = Date.now();
|
|
2098
|
+
const drainedCutoff = now - Math.max(0, opts.drainedOlderThanMs);
|
|
2099
|
+
const undrainedCutoff = now - Math.max(0, opts.undrainedOlderThanMs);
|
|
2100
|
+
let removed = 0;
|
|
2101
|
+
removed += this.db.prepare(
|
|
2102
|
+
'DELETE FROM mesh_pending_events WHERE drained = 1 AND queued_at < ?'
|
|
2103
|
+
).run(drainedCutoff).changes;
|
|
2104
|
+
removed += this.db.prepare(
|
|
2105
|
+
'DELETE FROM mesh_pending_events WHERE drained = 0 AND queued_at < ?'
|
|
2106
|
+
).run(undrainedCutoff).changes;
|
|
2107
|
+
return removed;
|
|
2108
|
+
}
|
|
2056
2109
|
}
|
|
@@ -221,6 +221,34 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
221
221
|
*/
|
|
222
222
|
private static readonly AUTO_APPROVE_GATE_HYSTERESIS_MS = 1500;
|
|
223
223
|
|
|
224
|
+
/**
|
|
225
|
+
* AUTOAPPROVE-FLAP-RECUR (Fix B): extended busy-side continuity window for a
|
|
226
|
+
* DELEGATED-WORKER auto-approve episode that is genuinely still cycling.
|
|
227
|
+
*
|
|
228
|
+
* The default AUTO_APPROVE_GATE_HYSTERESIS_MS (1500) absorbs a *momentary*
|
|
229
|
+
* `generating` blip. But a delegated worker running a Bash approval observed
|
|
230
|
+
* the FSM cycle the FULL state waiting_approval → busy → waiting_approval on a
|
|
231
|
+
* 2–5s period (the button set scrolls in/out AND the modal question repaints,
|
|
232
|
+
* so the adapter genuinely reports status=generating for whole seconds between
|
|
233
|
+
* approval frames). Each busy phase outran the 1500ms hysteresis, so the
|
|
234
|
+
* settle clock was WIPED (the genuine-resolution branch), the 600ms settle
|
|
235
|
+
* window never accumulated across the flap, resolveModal never fired
|
|
236
|
+
* (resolveModal count 0), and the mask-stall clock instead tripped at 4500ms →
|
|
237
|
+
* coordinator nudge → the flap the coordinator observed.
|
|
238
|
+
*
|
|
239
|
+
* A genuine resolution and a flap both start with a busy phase; they diverge
|
|
240
|
+
* only in whether waiting_approval RETURNS. So we cannot simply lengthen the
|
|
241
|
+
* blanket hysteresis (that would make every real resolution hold the gate
|
|
242
|
+
* open for seconds). Instead this longer window applies ONLY while an active
|
|
243
|
+
* mask episode is alive (autoApproveMaskSince > 0) AND the session is a
|
|
244
|
+
* delegated worker — i.e. exactly the never-resolving-flap case. A foreground
|
|
245
|
+
* / attended session keeps the tight 1500ms window unchanged. The mask-stall
|
|
246
|
+
* bound below still caps the episode, so a worker whose approval truly never
|
|
247
|
+
* returns is surfaced to the coordinator within AUTO_APPROVE_MASK_STALL_MS
|
|
248
|
+
* rather than held forever.
|
|
249
|
+
*/
|
|
250
|
+
private static readonly AUTO_APPROVE_FLAP_CONTINUITY_MS = 4000;
|
|
251
|
+
|
|
224
252
|
/**
|
|
225
253
|
* STATUS-MISMATCH: upper bound on how long the auto-approve→`generating` SURFACE
|
|
226
254
|
* mask may hide a worker's `waiting_approval` (status + activeModal) before we give
|
|
@@ -296,9 +324,22 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
296
324
|
private pendingAutoApprovalSince = 0;
|
|
297
325
|
private autoApproveSettleTimer: NodeJS.Timeout | null = null;
|
|
298
326
|
// Wall-clock when auto-approve first observed status!=waiting_approval while
|
|
299
|
-
// a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS
|
|
327
|
+
// a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS (or,
|
|
328
|
+
// for a delegated-worker flap episode, AUTO_APPROVE_FLAP_CONTINUITY_MS) so a
|
|
300
329
|
// brief generating flip does not immediately wipe the settle clock.
|
|
301
330
|
private autoApproveInactiveSince = 0;
|
|
331
|
+
// AUTOAPPROVE-FLAP-RECUR (Fix A): wall-clock when the CURRENT waiting_approval
|
|
332
|
+
// episode last presented a concrete, captured modal (buttons.length > 0). The
|
|
333
|
+
// Claude TUI momentarily reports status=waiting_approval with activeModal=null
|
|
334
|
+
// / an empty button block while the button block scrolls out of the captured
|
|
335
|
+
// frame; the raw guard below (buttons.length===0) used to bail on that frame,
|
|
336
|
+
// never advancing the settle gate and leaving no re-check armed — so a modal
|
|
337
|
+
// that flapped modal=none ↔ N-buttons around the settle boundary never
|
|
338
|
+
// accumulated its 600ms. This tracks the last GOOD-modal frame so a short
|
|
339
|
+
// scroll-out blip is absorbed (settle keeps running against the last captured
|
|
340
|
+
// signature) while a genuinely closed modal — buttons empty continuously past
|
|
341
|
+
// the continuity window — is still recognised and resets the gate.
|
|
342
|
+
private autoApproveLastModalSeenAt = 0;
|
|
302
343
|
// STATUS-MISMATCH: wall-clock when the CURRENT auto-approve episode (waiting_approval
|
|
303
344
|
// + shouldAutoApprove) first began wanting to mask. Unlike pendingAutoApprovalSince it
|
|
304
345
|
// is NOT reset when the modal signature changes (a still-streaming/flapping prompt) and
|
|
@@ -1640,6 +1681,23 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1640
1681
|
|| this.settings.meshNodeId || this.settings.launchedByCoordinator);
|
|
1641
1682
|
}
|
|
1642
1683
|
|
|
1684
|
+
/**
|
|
1685
|
+
* AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
|
|
1686
|
+
* persist before the in-progress settle gate is torn down. For a delegated
|
|
1687
|
+
* worker whose auto-approve episode is genuinely still cycling (mask clock
|
|
1688
|
+
* alive), the FSM's full waiting_approval → busy → waiting_approval flap runs
|
|
1689
|
+
* on a multi-second period, so the settle continuity window is extended to
|
|
1690
|
+
* AUTO_APPROVE_FLAP_CONTINUITY_MS to bridge it (still bounded, and still capped
|
|
1691
|
+
* by AUTO_APPROVE_MASK_STALL_MS). Every other case — foreground/attended
|
|
1692
|
+
* session, or no active mask episode — keeps the tight default hysteresis so a
|
|
1693
|
+
* genuine resolution frees the gate promptly.
|
|
1694
|
+
*/
|
|
1695
|
+
private autoApproveContinuityWindowMs(): number {
|
|
1696
|
+
return this.autoApproveMaskSince > 0 && this.isMeshWorkerSession()
|
|
1697
|
+
? CliProviderInstance.AUTO_APPROVE_FLAP_CONTINUITY_MS
|
|
1698
|
+
: CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1643
1701
|
// FALSE-IDLE (self-coordinator settle): an autonomously-progressing mesh session
|
|
1644
1702
|
// is either a delegated worker (isMeshWorkerSession) OR the coordinator's OWN
|
|
1645
1703
|
// claude-cli session (meshCoordinatorFor). Both run auto-approved tool turns whose
|
|
@@ -1946,6 +2004,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1946
2004
|
// returns false), so end the mask episode.
|
|
1947
2005
|
this.autoApproveMaskSince = 0;
|
|
1948
2006
|
this.stalledApprovalNudgeEpisode = 0;
|
|
2007
|
+
this.autoApproveLastModalSeenAt = 0;
|
|
1949
2008
|
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
1950
2009
|
this.autoApproveSettleTimer = setTimeout(() => {
|
|
1951
2010
|
this.autoApproveSettleTimer = null;
|
|
@@ -1973,12 +2032,20 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1973
2032
|
if (this.pendingAutoApprovalSince) {
|
|
1974
2033
|
if (!this.autoApproveInactiveSince) this.autoApproveInactiveSince = now;
|
|
1975
2034
|
const goneForMs = now - this.autoApproveInactiveSince;
|
|
1976
|
-
|
|
2035
|
+
// AUTOAPPROVE-FLAP-RECUR (Fix B): a delegated-worker flap cycles the
|
|
2036
|
+
// FULL waiting_approval → busy → waiting_approval state on a
|
|
2037
|
+
// multi-second period, outrunning the tight default hysteresis and
|
|
2038
|
+
// wiping the settle clock before 600ms ever accumulates. For an
|
|
2039
|
+
// active worker mask episode the continuity window is widened (still
|
|
2040
|
+
// capped by AUTO_APPROVE_MASK_STALL_MS) so the settle clock survives
|
|
2041
|
+
// the busy phase and the returning approval keeps accruing settle time.
|
|
2042
|
+
const continuityMs = this.autoApproveContinuityWindowMs();
|
|
2043
|
+
if (goneForMs < continuityMs) {
|
|
1977
2044
|
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
1978
2045
|
this.autoApproveSettleTimer = setTimeout(() => {
|
|
1979
2046
|
this.autoApproveSettleTimer = null;
|
|
1980
2047
|
this.recheckAutoApproveSettled();
|
|
1981
|
-
},
|
|
2048
|
+
}, continuityMs - goneForMs + 20);
|
|
1982
2049
|
return autoApproveActive;
|
|
1983
2050
|
}
|
|
1984
2051
|
}
|
|
@@ -1991,6 +2058,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1991
2058
|
// end the mask episode too (a later approval starts a fresh stall clock).
|
|
1992
2059
|
this.autoApproveMaskSince = 0;
|
|
1993
2060
|
this.stalledApprovalNudgeEpisode = 0;
|
|
2061
|
+
this.autoApproveLastModalSeenAt = 0;
|
|
1994
2062
|
if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
|
|
1995
2063
|
return autoApproveActive;
|
|
1996
2064
|
}
|
|
@@ -2018,8 +2086,40 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2018
2086
|
? modal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
|
|
2019
2087
|
: [];
|
|
2020
2088
|
if (!modal || buttons.length === 0) {
|
|
2089
|
+
// AUTOAPPROVE-FLAP-RECUR (Fix A): the button block momentarily scrolled
|
|
2090
|
+
// out of the captured frame (status is still waiting_approval — we are
|
|
2091
|
+
// on the active path). Do NOT tear the settle gate down on this frame:
|
|
2092
|
+
// if a concrete modal was captured within the continuity window and a
|
|
2093
|
+
// settle gate is in progress, this is a short scroll-out blip — keep the
|
|
2094
|
+
// gate warm against the last-captured signature and arm a re-check so a
|
|
2095
|
+
// silent PTY still re-drives the decision when the buttons repaint. Only
|
|
2096
|
+
// once the modal has stayed empty PAST the continuity window is it a
|
|
2097
|
+
// genuine close, and the gate is cleared here so a later approval
|
|
2098
|
+
// re-settles from scratch (never fires on a stale timestamp).
|
|
2099
|
+
const blipForMs = this.autoApproveLastModalSeenAt ? now - this.autoApproveLastModalSeenAt : Infinity;
|
|
2100
|
+
if (this.pendingAutoApprovalSince && blipForMs < this.autoApproveContinuityWindowMs()) {
|
|
2101
|
+
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
2102
|
+
this.autoApproveSettleTimer = setTimeout(() => {
|
|
2103
|
+
this.autoApproveSettleTimer = null;
|
|
2104
|
+
this.recheckAutoApproveSettled();
|
|
2105
|
+
}, this.autoApproveContinuityWindowMs() - blipForMs + 20);
|
|
2106
|
+
return autoApproveActive;
|
|
2107
|
+
}
|
|
2108
|
+
if (blipForMs >= this.autoApproveContinuityWindowMs()) {
|
|
2109
|
+
// Buttons empty continuously past the window → the modal genuinely
|
|
2110
|
+
// closed (or never captured). Reset the per-signature settle gate so
|
|
2111
|
+
// a later approval re-settles cleanly. The mask-stall clock keeps
|
|
2112
|
+
// running underneath so a never-captured worker modal still surfaces
|
|
2113
|
+
// to the coordinator within AUTO_APPROVE_MASK_STALL_MS.
|
|
2114
|
+
this.pendingAutoApprovalSignature = '';
|
|
2115
|
+
this.pendingAutoApprovalSince = 0;
|
|
2116
|
+
}
|
|
2021
2117
|
return autoApproveActive;
|
|
2022
2118
|
}
|
|
2119
|
+
// Concrete modal captured this frame — mark the last-good-modal timestamp so
|
|
2120
|
+
// a subsequent scroll-out blip (buttons.length===0) can be told apart from a
|
|
2121
|
+
// genuine close by how long it persists (Fix A above).
|
|
2122
|
+
this.autoApproveLastModalSeenAt = now;
|
|
2023
2123
|
// Picker/confirm exclusion (provider-common). A /model or /mode picker is
|
|
2024
2124
|
// surfaced with status=waiting_approval so the dashboard shows it, but it
|
|
2025
2125
|
// has no "correct" answer to auto-pick — blindly selecting the first
|
|
@@ -2137,6 +2237,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2137
2237
|
// Fired (resolveModal in flight) — the episode resolved; end the mask-stall clock.
|
|
2138
2238
|
this.autoApproveMaskSince = 0;
|
|
2139
2239
|
this.stalledApprovalNudgeEpisode = 0;
|
|
2240
|
+
this.autoApproveLastModalSeenAt = 0;
|
|
2140
2241
|
if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
|
|
2141
2242
|
this.autoApproveBusyTimer = setTimeout(() => {
|
|
2142
2243
|
this.autoApproveBusy = false;
|
|
@@ -3162,6 +3263,18 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
3162
3263
|
if (!this.isMeshWorkerSession()) return;
|
|
3163
3264
|
if (adapterStatus?.status !== 'waiting_approval') return;
|
|
3164
3265
|
if (!this.autoApproveMaskStalled(now)) return;
|
|
3266
|
+
// AUTOAPPROVE-FLAP-RECUR (Fix C): the mask-stall bound tripped, but if a
|
|
3267
|
+
// concrete approvable modal is on screen RIGHT NOW and the settle gate is
|
|
3268
|
+
// already in progress, auto-approve is about to fire on its own (the same
|
|
3269
|
+
// call runs the settle evaluation just below this nudge). Defer to the fire
|
|
3270
|
+
// rather than paging the coordinator — the nudge would race a resolveModal
|
|
3271
|
+
// that lands milliseconds later, producing the coordinator flap this fix
|
|
3272
|
+
// targets. A genuinely stuck episode (no captured modal, or settle never
|
|
3273
|
+
// engaged) still has pendingAutoApprovalSince === 0 here and pages normally.
|
|
3274
|
+
const modalButtons = Array.isArray(adapterStatus.activeModal?.buttons)
|
|
3275
|
+
? adapterStatus.activeModal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
|
|
3276
|
+
: [];
|
|
3277
|
+
if (this.pendingAutoApprovalSince && modalButtons.length > 0) return;
|
|
3165
3278
|
// Exactly once per stalled episode (autoApproveMaskSince uniquely identifies it).
|
|
3166
3279
|
if (this.stalledApprovalNudgeEpisode === this.autoApproveMaskSince) return;
|
|
3167
3280
|
this.stalledApprovalNudgeEpisode = this.autoApproveMaskSince;
|