@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.
@@ -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.465",
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.465",
51
- "@adhdev/session-host-core": "0.9.82-rc.465",
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;
@@ -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 so a
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
- if (goneForMs < CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS) {
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
- }, CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS - goneForMs + 20);
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;