@adhdev/daemon-core 0.9.82-rc.464 → 0.9.82-rc.466

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
  *
@@ -183,6 +191,16 @@ export declare function getPendingMeshCoordinatorEvents(meshId?: string, coordin
183
191
  * (including drained fingerprint history) and JSONL files.
184
192
  */
185
193
  export declare function __clearMeshPendingEventsForTests(meshId: string): void;
194
+ /**
195
+ * Test helper: persist a pending event VERBATIM, skipping the emit-time v2 stamp.
196
+ * The local emit path (queuePendingMeshCoordinatorEvent → stampPendingEventV2) now
197
+ * always mints a v2 envelope (self-daemon broadcast fallback when no coordinator
198
+ * identity is present), so a genuinely-unversioned (v1) row can no longer be produced
199
+ * through the normal queue. The drain-side v1 handling (accept-broadcast / enforce-
200
+ * quarantine) still matters for durable v1 rows written by a pre-v2 daemon and for
201
+ * version-skewed remote relays, so tests inject those rows directly through this.
202
+ */
203
+ export declare function __persistUnstampedPendingEventForTests(event: PendingMeshCoordinatorEvent): boolean;
186
204
  /** Explicitly clear all pending coordinator events for a mesh (and coordinator if scoped). */
187
205
  export declare function clearPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): void;
188
206
  export {};
@@ -0,0 +1,17 @@
1
+ export declare const ACKED_DEATH_CONSECUTIVE_READ_FAILURES = 3;
2
+ export declare function resolveTunedReconcileMs(envName: string, def: number, min: number, max: number): number;
3
+ export declare function resolveAckedDeathDeadlineMs(): number;
4
+ export declare function resolveAckedTranscriptFastTrackGraceMs(): number;
5
+ export interface AckedHoldState {
6
+ liveConfirmedSinceAck: boolean;
7
+ consecutiveReadFailures: number;
8
+ transcriptIdleSinceMs?: number;
9
+ }
10
+ export declare const inFlightAckedHoldState: Map<string, AckedHoldState>;
11
+ export declare function inFlightSynthKey(meshId: string, taskId: string): string;
12
+ export declare function getHoldState(synthKey: string, meshId: string): AckedHoldState | undefined;
13
+ export declare function setHoldState(synthKey: string, meshId: string, state: AckedHoldState): void;
14
+ export declare function deleteHoldState(synthKey: string, meshId: string): void;
15
+ export declare function rehydrateAckedHoldsForMesh(meshId: string): void;
16
+ export declare function collectHeldSynthKeysForMesh(meshId: string): Set<string>;
17
+ export declare function __resetReconcileInFlightSynthDebounceForTests(): void;
@@ -0,0 +1,6 @@
1
+ import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
2
+ import type { LocalMeshEntry } from '../repo-mesh-types.js';
3
+ export declare function resolveCoordinatorDaemonIds(components: DaemonComponents): string[];
4
+ export declare function daemonHostsMesh(mesh: LocalMeshEntry, daemonIds: string[]): boolean;
5
+ export declare function daemonIdListIncludes(ids: readonly string[], id: string | undefined): boolean;
6
+ export declare function resolveCoordinatorSelfIds(mesh: LocalMeshEntry, drainDaemonIds: string[]): string[];
@@ -1,17 +1,6 @@
1
1
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
2
- declare const meshV2BackstopCounters: {
3
- /** PHASE-4 transcript synthesis actually reconciled a missing completion. */
4
- phase4SynthesisFired: number;
5
- /** Acked-hold transcript fast-track promoted a synth ahead of the death deadline. */
6
- ackedHoldFastTrackFired: number;
7
- /** Acked-hold death-deadline backstop released a held synth (the 8-min net). */
8
- ackedHoldDeathDeadlineFired: number;
9
- };
10
- /** Test/observability accessor for the v2 last-resort backstop counters (snapshot). */
11
- export declare function getMeshV2BackstopCounters(): Readonly<typeof meshV2BackstopCounters>;
12
- /** Test helper: zero the backstop counters so a case starts from a clean slate. */
13
- export declare function __resetMeshV2BackstopCountersForTests(): void;
14
- export declare function __resetReconcileInFlightSynthDebounceForTests(): void;
2
+ export { getMeshV2BackstopCounters, __resetMeshV2BackstopCountersForTests } from './mesh-reconcile-v2-backstop.js';
3
+ export { __resetReconcileInFlightSynthDebounceForTests } from './mesh-reconcile-acked-hold.js';
15
4
  /**
16
5
  * DRAIN-WITHOUT-INJECT guard. Classify, for a mesh on THIS daemon, whether a
17
6
  * queue-drain caller (the MCP `get_pending_mesh_events` poll) may safely consume
@@ -89,4 +78,3 @@ interface ReconcileLoopHandle {
89
78
  stop(): void;
90
79
  }
91
80
  export declare function setupMeshReconcileLoop(components: DaemonComponents): ReconcileLoopHandle;
92
- export {};
@@ -0,0 +1,16 @@
1
+ declare const meshV2BackstopCounters: {
2
+ /** PHASE-4 transcript synthesis actually reconciled a missing completion. */
3
+ phase4SynthesisFired: number;
4
+ /** Acked-hold transcript fast-track promoted a synth ahead of the death deadline. */
5
+ ackedHoldFastTrackFired: number;
6
+ /** Acked-hold death-deadline backstop released a held synth (the 8-min net). */
7
+ ackedHoldDeathDeadlineFired: number;
8
+ };
9
+ /** Test/observability accessor for the v2 last-resort backstop counters (snapshot). */
10
+ export declare function getMeshV2BackstopCounters(): Readonly<typeof meshV2BackstopCounters>;
11
+ /** Test helper: zero the backstop counters so a case starts from a clean slate. */
12
+ export declare function __resetMeshV2BackstopCountersForTests(): void;
13
+ /** Bump a backstop counter and, under enforce, WARN that a last-resort net fired —
14
+ * which under a healthy v2 contract should not happen (the real emit was lost). */
15
+ export declare function recordBackstopFire(kind: keyof typeof meshV2BackstopCounters, detail: string): void;
16
+ export {};
@@ -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
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * CLI provider persisted-history dedup — incremental append computation.
3
+ *
4
+ * Pure move out of cli-provider-instance.ts (no behavior change): the
5
+ * shared-prefix diff that turns a full parsed transcript into the newly-added
6
+ * tail to append to the persisted chat history. cli-provider-instance
7
+ * re-exports buildIncrementalHistoryAppendMessages so existing importers/tests
8
+ * keep their path.
9
+ */
10
+ export type PersistableCliHistoryMessage = {
11
+ role: string;
12
+ content: string;
13
+ kind?: string;
14
+ senderName?: string;
15
+ receivedAt?: number;
16
+ };
17
+ export declare function buildIncrementalHistoryAppendMessages(previousMessages: PersistableCliHistoryMessage[], currentMessages: PersistableCliHistoryMessage[]): PersistableCliHistoryMessage[];
@@ -0,0 +1,12 @@
1
+ /**
2
+ * CLI provider structured-input helpers — image materialization + prompt build.
3
+ *
4
+ * Pure move out of cli-provider-instance.ts (no behavior change): the input
5
+ * envelope → CLI prompt string construction and its image-materialization
6
+ * support. cli-provider-instance re-exports buildCliStructuredInputPrompt so
7
+ * existing importers/tests keep their path.
8
+ */
9
+ import { type InputEnvelope } from './contracts.js';
10
+ export declare function buildCliStructuredInputPrompt(input: InputEnvelope, options?: {
11
+ materializeDir?: string;
12
+ }): string;
@@ -9,40 +9,9 @@ import type { ProviderInstance, ProviderState, InstanceContext, HotChatSessionSt
9
9
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
10
10
  import type { PtyTransportFactory } from '../cli-adapters/pty-transport.js';
11
11
  import type { ChatMessage } from '../types.js';
12
- type PersistableCliHistoryMessage = {
13
- role: string;
14
- content: string;
15
- kind?: string;
16
- senderName?: string;
17
- receivedAt?: number;
18
- };
19
- /**
20
- * NOTIF Defect-2a: the REPORTED short-generating duration, anchored on the IMMUTABLE turn
21
- * start. generatingStartedAt is reset to 0 on every mid-turn waiting_approval/idle blip and
22
- * re-armed on the next →generating, so a long turn that blips would otherwise measure only the
23
- * final 1.5-2.5s sliver. engine.currentTurnStartedAt (set once at onTurnStarted, surviving
24
- * mid-turn blips until the next turn starts) is preferred; generatingStartedAt is the fallback
25
- * for turns that never recorded an engine turn start. Returns 0 when neither anchor is set.
26
- * Pure / unit-testable.
27
- */
28
- export declare function computeTurnAnchoredDurationMs(engineTurnStartedAt: number | undefined, generatingStartedAt: number, now: number): {
29
- durationMs: number;
30
- anchor: 'turn-start' | 'generatingStartedAt' | 'none';
31
- };
32
- export declare function buildCliStructuredInputPrompt(input: InputEnvelope, options?: {
33
- materializeDir?: string;
34
- }): string;
35
- export declare function buildIncrementalHistoryAppendMessages(previousMessages: PersistableCliHistoryMessage[], currentMessages: PersistableCliHistoryMessage[]): PersistableCliHistoryMessage[];
36
- export declare function getForcedNewSessionScriptName(provider: ProviderModule | undefined, launchMode: 'new' | 'resume' | 'manual'): string | null;
37
- export declare function waitForCliAdapterReady(adapter: {
38
- isReady?: () => boolean;
39
- getStatus?: () => {
40
- status?: string;
41
- };
42
- }, options?: {
43
- timeoutMs?: number;
44
- pollMs?: number;
45
- }): Promise<void>;
12
+ export { buildCliStructuredInputPrompt } from './cli-provider-input-prompt.js';
13
+ export { buildIncrementalHistoryAppendMessages } from './cli-provider-history-dedup.js';
14
+ export { computeTurnAnchoredDurationMs, getForcedNewSessionScriptName, waitForCliAdapterReady, } from './cli-provider-status-helpers.js';
46
15
  export declare class CliProviderInstance implements ProviderInstance {
47
16
  private provider;
48
17
  private workingDir;
@@ -75,6 +44,33 @@ export declare class CliProviderInstance implements ProviderInstance {
75
44
  * from scratch rather than firing on a stale timestamp.
76
45
  */
77
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;
78
74
  /**
79
75
  * STATUS-MISMATCH: upper bound on how long the auto-approve→`generating` SURFACE
80
76
  * mask may hide a worker's `waiting_approval` (status + activeModal) before we give
@@ -114,6 +110,7 @@ export declare class CliProviderInstance implements ProviderInstance {
114
110
  private pendingAutoApprovalSince;
115
111
  private autoApproveSettleTimer;
116
112
  private autoApproveInactiveSince;
113
+ private autoApproveLastModalSeenAt;
117
114
  private autoApproveMaskSince;
118
115
  private stalledApprovalNudgeEpisode;
119
116
  private readonly manualAttendance;
@@ -303,6 +300,18 @@ export declare class CliProviderInstance implements ProviderInstance {
303
300
  private approvalResolutionFinalizationBlock;
304
301
  private scheduleCompletedDebounceFlush;
305
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;
306
315
  private isAutonomousMeshSession;
307
316
  /**
308
317
  * ARCH-REFACTOR R1: the taskId to attribute the CURRENTLY-completing turn to.
@@ -429,4 +438,3 @@ export declare class CliProviderInstance implements ProviderInstance {
429
438
  private buildSqlPlaceholderList;
430
439
  private querySqliteText;
431
440
  }
432
- export {};
@@ -0,0 +1,46 @@
1
+ /**
2
+ * CLI provider status/launch pure helpers.
3
+ *
4
+ * Pure move out of cli-provider-instance.ts (no behavior change): the
5
+ * side-effect-free status predicates, the turn-anchored duration computation,
6
+ * the forced-new-session script resolver, the adapter-ready poll, and the lazy
7
+ * node:sqlite DatabaseSync loader. cli-provider-instance re-exports the
8
+ * public symbols (computeTurnAnchoredDurationMs, getForcedNewSessionScriptName,
9
+ * waitForCliAdapterReady) so existing importers/tests keep their path.
10
+ */
11
+ import type { ProviderModule } from './contracts.js';
12
+ export declare function isIdleStatus(value: unknown): boolean;
13
+ export declare function getMessageTime(message: unknown): number;
14
+ export declare function hasNonEmptyCliModalButtons(activeModal: unknown): boolean;
15
+ export declare function isCliGeneratingLikeStatus(status: unknown): boolean;
16
+ /**
17
+ * NOTIF Defect-2a: the REPORTED short-generating duration, anchored on the IMMUTABLE turn
18
+ * start. generatingStartedAt is reset to 0 on every mid-turn waiting_approval/idle blip and
19
+ * re-armed on the next →generating, so a long turn that blips would otherwise measure only the
20
+ * final 1.5-2.5s sliver. engine.currentTurnStartedAt (set once at onTurnStarted, surviving
21
+ * mid-turn blips until the next turn starts) is preferred; generatingStartedAt is the fallback
22
+ * for turns that never recorded an engine turn start. Returns 0 when neither anchor is set.
23
+ * Pure / unit-testable.
24
+ */
25
+ export declare function computeTurnAnchoredDurationMs(engineTurnStartedAt: number | undefined, generatingStartedAt: number, now: number): {
26
+ durationMs: number;
27
+ anchor: 'turn-start' | 'generatingStartedAt' | 'none';
28
+ };
29
+ export declare function getDatabaseSync(): new (path: string, options?: {
30
+ readOnly?: boolean;
31
+ }) => {
32
+ prepare(sql: string): {
33
+ get(...params: Array<string | number>): unknown;
34
+ };
35
+ close(): void;
36
+ };
37
+ export declare function getForcedNewSessionScriptName(provider: ProviderModule | undefined, launchMode: 'new' | 'resume' | 'manual'): string | null;
38
+ export declare function waitForCliAdapterReady(adapter: {
39
+ isReady?: () => boolean;
40
+ getStatus?: () => {
41
+ status?: string;
42
+ };
43
+ }, options?: {
44
+ timeoutMs?: number;
45
+ pollMs?: number;
46
+ }): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.464",
3
+ "version": "0.9.82-rc.466",
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.464",
51
- "@adhdev/session-host-core": "0.9.82-rc.464",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.466",
51
+ "@adhdev/session-host-core": "0.9.82-rc.466",
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;
@@ -2,6 +2,7 @@ import { appendFileSync, existsSync, readFileSync, renameSync, statSync, unlinkS
2
2
  import { join } from 'path';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { LOG } from '../logging/logger.js';
5
+ import { loadConfig } from '../config/config.js';
5
6
  import { getLedgerDir, readLedgerEntries, appendLedgerEntry } from './mesh-ledger.js';
6
7
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
7
8
  import { buildMeshSystemMessage, readNonEmptyString, readRecord, resolveEventSessionId, readMeshCompletionSummary, isWeakCompletionMetadata } from './mesh-events-utils.js';
@@ -674,6 +675,42 @@ function reconcilePendingMeshCoordinatorEvents(meshId: string, events: PendingMe
674
675
  const MAX_PENDING_EVENTS_BYTES = 100 * 1024; // 100 KB — keep the pending file small
675
676
  const MAX_PENDING_EVENTS_KEEP = 50; // keep the last 50 events when trimming
676
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
+
677
714
  function trimPendingEventsIfNeeded(path: string): void {
678
715
  try {
679
716
  if (!existsSync(path)) return;
@@ -746,23 +783,51 @@ export function stampPendingEventV2(
746
783
  return event;
747
784
  }
748
785
 
749
- const dispatchedBy = hint?.dispatchedBy ?? coordinatorIdentityFromEmitFields({
786
+ const coordinatorIdentity = hint?.dispatchedBy ?? coordinatorIdentityFromEmitFields({
750
787
  daemonId: event.targetCoordinatorDaemonId,
751
788
  coordinatorRunId: hint?.coordinatorRunId,
752
789
  sessionId: event.targetCoordinatorSessionId,
753
790
  });
791
+
792
+ // A/C ROOT FIX: an emit site with NO coordinator identity (direct-dispatch /
793
+ // refine notification / any path where the worker session never carried a
794
+ // meshCoordinatorDaemonId) used to leave the event UNVERSIONED (v1). Under v2
795
+ // enforce (default ON) routeV2EventsForDrainer QUARANTINES every unversioned
796
+ // event — so a summary-less completion (agent:generating_completed) and every
797
+ // refine terminal notification (refine:accepted/completed/failed) were held
798
+ // back and never reached the coordinator; only the backstop papered over it.
799
+ //
800
+ // Fall back to THIS daemon's own id as the dispatcher so a v2 envelope can
801
+ // still be minted. There is no addressable coordinator, so we intentionally
802
+ // leave intendedFor empty and let buildPendingEventEmitStamp downgrade the
803
+ // (unicast-defaulting) terminal event to a BROADCAST — deliverable to whatever
804
+ // coordinator drains on this machine, instead of an undeliverable v1 event.
805
+ // When a real coordinator identity DOES exist the unicast path below is
806
+ // unchanged (no regression). loadConfig().machineId is the same self-id source
807
+ // resolveCoordinatorDaemonIds / the local queue-assignment stamp use, so the
808
+ // broadcast dispatcher matches the drainer's own identity form.
809
+ const selfFallback = !coordinatorIdentity;
810
+ const dispatchedBy = coordinatorIdentity ?? coordinatorIdentityFromEmitFields({
811
+ daemonId: readNonEmptyString(loadConfig().machineId),
812
+ });
754
813
  // The unicast target is, by default, the same coordinator the event is already
755
- // routed to (its originating coordinator). A hint may override it.
756
- const intendedFor: CoordinatorIdentity | undefined = hint?.intendedFor ?? dispatchedBy;
814
+ // routed to (its originating coordinator). A hint may override it. In the
815
+ // self-fallback case there is no originating coordinator to address, so leave
816
+ // it empty → broadcast (never a self-unicast that a sibling session's drainer
817
+ // would skip).
818
+ const intendedFor: CoordinatorIdentity | undefined = hint?.intendedFor
819
+ ?? (selfFallback ? undefined : coordinatorIdentity);
757
820
 
758
821
  const stamp = buildPendingEventEmitStamp({
759
822
  eventName: event.event,
760
823
  eventId: randomUUID(),
761
824
  dispatchedBy,
762
825
  intendedFor,
763
- scope: hint?.scope,
826
+ // Force broadcast for the self-fallback so a unicast-defaulting terminal
827
+ // event isn't addressed to this daemon alone; an explicit hint still wins.
828
+ scope: hint?.scope ?? (selfFallback ? 'broadcast' : undefined),
764
829
  });
765
- if (!stamp) return event; // no coordinator identity → stays a v1 event
830
+ if (!stamp) return event; // no coordinator identity at all (no self id) → stays a v1 event
766
831
 
767
832
  return {
768
833
  ...event,
@@ -849,6 +914,17 @@ export function queuePendingMeshCoordinatorEvent(
849
914
  // B2a: stamp the v2 envelope before dedup/persist so the eventId/scope ride
850
915
  // into both stores and the fingerprint/dedup logic sees the final shape.
851
916
  const event = stampPendingEventV2(rawEvent, hint);
917
+ return persistPendingMeshCoordinatorEvent(event);
918
+ }
919
+
920
+ /**
921
+ * Persist an ALREADY-STAMPED pending event to both stores (dedup + SQLite + JSONL),
922
+ * without re-running the emit stamp. queuePendingMeshCoordinatorEvent stamps then
923
+ * calls this; the only other caller is the test helper below, which needs to inject
924
+ * a genuinely-unversioned (v1) row to exercise the drain-side v1 handling now that
925
+ * the emit path never produces one (self-daemon fallback stamps every local emit).
926
+ */
927
+ function persistPendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean {
852
928
  try {
853
929
  if (hasPendingRefineTerminalEventDuplicate(event)) {
854
930
  LOG.info('MeshEvents', `Suppressed duplicate pending ${event.event} for refine job ${readRefineJobId(event)}`);
@@ -1212,6 +1288,19 @@ export function __clearMeshPendingEventsForTests(meshId: string): void {
1212
1288
  clearPendingMeshCoordinatorEvents(meshId);
1213
1289
  }
1214
1290
 
1291
+ /**
1292
+ * Test helper: persist a pending event VERBATIM, skipping the emit-time v2 stamp.
1293
+ * The local emit path (queuePendingMeshCoordinatorEvent → stampPendingEventV2) now
1294
+ * always mints a v2 envelope (self-daemon broadcast fallback when no coordinator
1295
+ * identity is present), so a genuinely-unversioned (v1) row can no longer be produced
1296
+ * through the normal queue. The drain-side v1 handling (accept-broadcast / enforce-
1297
+ * quarantine) still matters for durable v1 rows written by a pre-v2 daemon and for
1298
+ * version-skewed remote relays, so tests inject those rows directly through this.
1299
+ */
1300
+ export function __persistUnstampedPendingEventForTests(event: PendingMeshCoordinatorEvent): boolean {
1301
+ return persistPendingMeshCoordinatorEvent(event);
1302
+ }
1303
+
1215
1304
  /** Explicitly clear all pending coordinator events for a mesh (and coordinator if scoped). */
1216
1305
  export function clearPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): void {
1217
1306
  if (!meshId) 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;