@adhdev/daemon-core 0.9.82-rc.459 → 0.9.82-rc.460

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.
@@ -237,15 +237,124 @@ interface AckedHoldState {
237
237
  consecutiveReadFailures: number;
238
238
  transcriptIdleSinceMs?: number;
239
239
  }
240
+
241
+ // T2 (B2b): acked-hold state persistence. The Map below is a process-local CACHE;
242
+ // the SSOT is the mesh_inflight_hold table in MeshRuntimeStore. Every read goes
243
+ // read-through (Map miss → load from store, then cache), every mutation goes
244
+ // write-through (Map set → store upsert; Map delete → store delete). On daemon
245
+ // boot the reconcile loop rehydrates the Map from the store per-mesh the first
246
+ // time it touches that mesh (rehydrateAckedHoldsForMesh), so a hold established
247
+ // before a restart survives it — closing the duplicate-emit / drop window the
248
+ // PHASE-4 transcript synth backstop otherwise had to correct after the fact.
249
+ //
250
+ // Store row ↔ AckedHoldState mapping:
251
+ // hold_reason 'live'|'unconfirmed' ↔ liveConfirmedSinceAck (boolean)
252
+ // read_failure_count ↔ consecutiveReadFailures
253
+ // first_idle_since_ack ↔ transcriptIdleSinceMs (undefined ⇒ NULL)
254
+ // mesh_id = the owning mesh (for listByMesh / prune)
255
+ // held_at = ms the hold was first created (store-managed)
240
256
  const inFlightAckedHoldState = new Map<string, AckedHoldState>();
257
+ // Meshes whose store rows have already been rehydrated into the Map this process.
258
+ // A restart resets this set, so the first touch of each mesh reloads from disk.
259
+ const rehydratedHoldMeshes = new Set<string>();
241
260
 
242
261
  function inFlightSynthKey(meshId: string, taskId: string): string {
243
262
  return `${meshId}::${taskId}`;
244
263
  }
245
264
 
246
- // Test hook: clear the in-flight acked-hold state between cases.
265
+ // Extract the taskId back out of a `${meshId}::${taskId}` synth key. The meshId
266
+ // prefix can itself contain '::' only if the caller passed one (mesh ids are
267
+ // config-derived and never do), so split on the FIRST '::' and treat the remainder
268
+ // as the taskId.
269
+ function taskIdFromSynthKey(meshId: string, synthKey: string): string {
270
+ const prefix = `${meshId}::`;
271
+ return synthKey.startsWith(prefix) ? synthKey.slice(prefix.length) : synthKey;
272
+ }
273
+
274
+ function holdStore(): MeshRuntimeStore | undefined {
275
+ try { return MeshRuntimeStore.getInstance(); } catch { return undefined; }
276
+ }
277
+
278
+ // Read-through: Map hit returns the cached state; a miss consults the store and,
279
+ // when a row exists, hydrates the Map from it before returning. A store failure
280
+ // degrades to Map-only (returns undefined on a miss) — identical to the pre-T2
281
+ // in-memory behavior, never worse.
282
+ function getHoldState(synthKey: string, meshId: string): AckedHoldState | undefined {
283
+ const cached = inFlightAckedHoldState.get(synthKey);
284
+ if (cached) return cached;
285
+ const store = holdStore();
286
+ if (!store) return undefined;
287
+ let row;
288
+ try { row = store.getInflightHold(taskIdFromSynthKey(meshId, synthKey)); } catch { return undefined; }
289
+ if (!row) return undefined;
290
+ const state: AckedHoldState = {
291
+ liveConfirmedSinceAck: row.holdReason === 'live',
292
+ consecutiveReadFailures: row.readFailureCount ?? 0,
293
+ ...(row.firstIdleSinceAck !== null && row.firstIdleSinceAck !== undefined
294
+ ? { transcriptIdleSinceMs: row.firstIdleSinceAck }
295
+ : {}),
296
+ };
297
+ inFlightAckedHoldState.set(synthKey, state);
298
+ return state;
299
+ }
300
+
301
+ // Write-through: update the Map cache AND the store row. A store failure leaves the
302
+ // Map authoritative for this process (degrade, never crash the tick).
303
+ function setHoldState(synthKey: string, meshId: string, state: AckedHoldState): void {
304
+ inFlightAckedHoldState.set(synthKey, state);
305
+ const store = holdStore();
306
+ if (!store) return;
307
+ try {
308
+ store.upsertInflightHold({
309
+ taskId: taskIdFromSynthKey(meshId, synthKey),
310
+ meshId,
311
+ holdReason: state.liveConfirmedSinceAck ? 'live' : 'unconfirmed',
312
+ firstIdleSinceAck: state.transcriptIdleSinceMs ?? null,
313
+ readFailureCount: state.consecutiveReadFailures,
314
+ });
315
+ } catch { /* degrade to Map-only */ }
316
+ }
317
+
318
+ // Write-through delete: drop the Map entry AND the store row.
319
+ function deleteHoldState(synthKey: string, meshId: string): void {
320
+ inFlightAckedHoldState.delete(synthKey);
321
+ const store = holdStore();
322
+ if (!store) return;
323
+ try { store.deleteInflightHold(taskIdFromSynthKey(meshId, synthKey)); } catch { /* degrade */ }
324
+ }
325
+
326
+ // Restart rehydration: on the first touch of a mesh this process, pull its persisted
327
+ // acked-hold rows from the store into the Map cache so a hold that outlived a daemon
328
+ // restart is honored again. Idempotent per process via rehydratedHoldMeshes. A store
329
+ // failure just skips rehydration (Map starts empty for the mesh — pre-T2 behavior).
330
+ function rehydrateAckedHoldsForMesh(meshId: string): void {
331
+ if (rehydratedHoldMeshes.has(meshId)) return;
332
+ rehydratedHoldMeshes.add(meshId);
333
+ const store = holdStore();
334
+ if (!store) return;
335
+ let rows;
336
+ try { rows = store.listInflightHoldsByMesh(meshId); } catch { return; }
337
+ for (const row of rows) {
338
+ const synthKey = inFlightSynthKey(meshId, row.taskId);
339
+ if (inFlightAckedHoldState.has(synthKey)) continue; // a live tick already set fresher state
340
+ inFlightAckedHoldState.set(synthKey, {
341
+ liveConfirmedSinceAck: row.holdReason === 'live',
342
+ consecutiveReadFailures: row.readFailureCount ?? 0,
343
+ ...(row.firstIdleSinceAck !== null && row.firstIdleSinceAck !== undefined
344
+ ? { transcriptIdleSinceMs: row.firstIdleSinceAck }
345
+ : {}),
346
+ });
347
+ }
348
+ if (rows.length > 0) {
349
+ LOG.info('MeshReconcile', `Rehydrated ${rows.length} persisted acked-hold row(s) for mesh ${meshId} after (re)start`);
350
+ }
351
+ }
352
+
353
+ // Test hook: clear the in-flight acked-hold state between cases (both the Map cache
354
+ // and the per-mesh rehydrate guard, so each case starts from a clean read-through).
247
355
  export function __resetReconcileInFlightSynthDebounceForTests(): void {
248
356
  inFlightAckedHoldState.clear();
357
+ rehydratedHoldMeshes.clear();
249
358
  }
250
359
 
251
360
  interface LiveCoordinator {
@@ -1836,21 +1945,42 @@ async function reconcileUnterminatedDirectDispatches(
1836
1945
  localDaemonId: string | undefined,
1837
1946
  ): Promise<void> {
1838
1947
  const dispatches = getActiveDirectDispatches(mesh.id);
1839
- if (dispatches.length === 0) return; // cheap exit — nothing dispatched, nothing to reconcile
1840
1948
 
1841
- // Prune the in-flight acked-hold map to the tasks still active in THIS mesh, so a
1842
- // completed/pruned task's state is dropped (the map never grows without bound).
1949
+ // T2 (B2b): restart rehydration. Reload this mesh's persisted acked-hold rows into
1950
+ // the Map cache the first time this process touches the mesh a hold established
1951
+ // before a daemon restart is honored again. Must run BEFORE the prune below so a
1952
+ // rehydrated hold for a still-active task is not seen as absent-from-cache and lost.
1953
+ rehydrateAckedHoldsForMesh(mesh.id);
1954
+
1955
+ // Prune the in-flight acked-hold state to the tasks still active in THIS mesh, so a
1956
+ // completed/pruned task's state is dropped (both the Map cache AND the store row —
1957
+ // the persisted table never grows without bound). Runs even when there are zero
1958
+ // active dispatches so a restart that landed after every task terminated still
1959
+ // reaps orphaned store rows. Iterate the union of Map keys and store rows so a row
1960
+ // that exists ONLY on disk (not yet cached) is pruned too.
1843
1961
  const activeTaskKeys = new Set(
1844
1962
  dispatches
1845
1963
  .map(d => readNonEmptyString(d.taskId))
1846
1964
  .filter(Boolean)
1847
1965
  .map(taskId => inFlightSynthKey(mesh.id, taskId)),
1848
1966
  );
1967
+ const heldKeys = new Set<string>();
1849
1968
  for (const key of inFlightAckedHoldState.keys()) {
1850
- if (key.startsWith(`${mesh.id}::`) && !activeTaskKeys.has(key)) {
1851
- inFlightAckedHoldState.delete(key);
1852
- }
1969
+ if (key.startsWith(`${mesh.id}::`)) heldKeys.add(key);
1853
1970
  }
1971
+ const store = holdStore();
1972
+ if (store) {
1973
+ try {
1974
+ for (const row of store.listInflightHoldsByMesh(mesh.id)) {
1975
+ heldKeys.add(inFlightSynthKey(mesh.id, row.taskId));
1976
+ }
1977
+ } catch { /* degrade — prune only what's in the Map */ }
1978
+ }
1979
+ for (const key of heldKeys) {
1980
+ if (!activeTaskKeys.has(key)) deleteHoldState(key, mesh.id);
1981
+ }
1982
+
1983
+ if (dispatches.length === 0) return; // nothing left to reconcile after the prune
1854
1984
 
1855
1985
  const dispatchMeshCommand = components.dispatchMeshCommand;
1856
1986
  const nodeById = new Map(mesh.nodes.map(n => [n.id, n] as const));
@@ -1918,12 +2048,12 @@ async function reconcileUnterminatedDirectDispatches(
1918
2048
  // we only record the death observation and STOP holding so those nets can take over,
1919
2049
  // rather than pinning the row on an indefinite hold for a session that is already gone.
1920
2050
  if (isAcked) {
1921
- const prior = inFlightAckedHoldState.get(synthKey);
2051
+ const prior = getHoldState(synthKey, mesh.id);
1922
2052
  const failures = (prior?.consecutiveReadFailures ?? 0) + 1;
1923
2053
  const liveConfirmedSinceAck = prior?.liveConfirmedSinceAck ?? false;
1924
2054
  // A read failure breaks the idle-with-final-assistant run → reset the fast-track streak
1925
2055
  // (transcriptIdleSinceMs cleared by omission) so it must re-accumulate from scratch.
1926
- inFlightAckedHoldState.set(synthKey, { liveConfirmedSinceAck, consecutiveReadFailures: failures });
2056
+ setHoldState(synthKey, mesh.id, { liveConfirmedSinceAck, consecutiveReadFailures: failures });
1927
2057
  if (liveConfirmedSinceAck && failures >= ACKED_DEATH_CONSECUTIVE_READ_FAILURES) {
1928
2058
  LOG.warn('MeshReconcile', `Acked-hold death signal: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read_chat failed ${failures}x consecutively after a live-confirmed ack — worker session presumed gone mid-turn; releasing the indefinite synth hold to the stranded-reclaim / orphan-prune nets`);
1929
2059
  }
@@ -1936,8 +2066,8 @@ async function reconcileUnterminatedDirectDispatches(
1936
2066
  // as a genuine liveness loss (backstop a) rather than a node that was never reachable. The
1937
2067
  // fast-track idle streak (transcriptIdleSinceMs) is PRESERVED across this reset — it is
1938
2068
  // managed below where the idle + final-assistant signal is actually evaluated.
1939
- const priorHoldState = inFlightAckedHoldState.get(synthKey);
1940
- inFlightAckedHoldState.set(synthKey, {
2069
+ const priorHoldState = getHoldState(synthKey, mesh.id);
2070
+ setHoldState(synthKey, mesh.id, {
1941
2071
  liveConfirmedSinceAck: true,
1942
2072
  consecutiveReadFailures: 0,
1943
2073
  ...(priorHoldState?.transcriptIdleSinceMs !== undefined ? { transcriptIdleSinceMs: priorHoldState.transcriptIdleSinceMs } : {}),
@@ -1951,7 +2081,7 @@ async function reconcileUnterminatedDirectDispatches(
1951
2081
  // Not idle → the worker is genuinely mid-turn (a clear live signal). Keep the
1952
2082
  // live-confirmed flag set (above) but RESET the fast-track idle streak: a turn that
1953
2083
  // resumed generating proves the prior idle was a mid-turn blip, not a settled turn-end.
1954
- inFlightAckedHoldState.set(synthKey, { liveConfirmedSinceAck: true, consecutiveReadFailures: 0 });
2084
+ setHoldState(synthKey, mesh.id, { liveConfirmedSinceAck: true, consecutiveReadFailures: 0 });
1955
2085
  continue;
1956
2086
  }
1957
2087
 
@@ -1989,12 +2119,12 @@ async function reconcileUnterminatedDirectDispatches(
1989
2119
  // ACKED-HOLD-IDLE-OVERTRUST fast-track. Maintain the continuous idle-with-final-assistant
1990
2120
  // streak. The streak starts (or continues) only while a final visible assistant message is
1991
2121
  // present; a tick with idle-but-no-assistant breaks it (the answer is not yet rendered).
1992
- const holdState = inFlightAckedHoldState.get(synthKey);
2122
+ const holdState = getHoldState(synthKey, mesh.id);
1993
2123
  let fastTrackReady = false;
1994
2124
  if (evidence.finalSummary) {
1995
2125
  const idleSinceMs = holdState?.transcriptIdleSinceMs ?? nowMs;
1996
2126
  if (holdState && holdState.transcriptIdleSinceMs === undefined) {
1997
- inFlightAckedHoldState.set(synthKey, { ...holdState, transcriptIdleSinceMs: idleSinceMs });
2127
+ setHoldState(synthKey, mesh.id, { ...holdState, transcriptIdleSinceMs: idleSinceMs });
1998
2128
  }
1999
2129
  const fastTrackGraceMs = resolveAckedTranscriptFastTrackGraceMs();
2000
2130
  const idleHeldMs = nowMs - idleSinceMs;
@@ -2004,7 +2134,7 @@ async function reconcileUnterminatedDirectDispatches(
2004
2134
  }
2005
2135
  } else if (holdState?.transcriptIdleSinceMs !== undefined) {
2006
2136
  // Idle but no final assistant yet → not a turn-end; reset the streak.
2007
- inFlightAckedHoldState.set(synthKey, { ...holdState, transcriptIdleSinceMs: undefined });
2137
+ setHoldState(synthKey, mesh.id, { ...holdState, transcriptIdleSinceMs: undefined });
2008
2138
  }
2009
2139
 
2010
2140
  // Hold indefinitely UNLESS the fast-track grace was met OR the absolute death deadline is
@@ -2027,7 +2157,7 @@ async function reconcileUnterminatedDirectDispatches(
2027
2157
  // never-acked path and the post-death-deadline acked synth from racing an emit caught in
2028
2158
  // flight at synth-commit time.
2029
2159
  if (realTerminalEmitPendingForTask(mesh.id, taskId)) {
2030
- inFlightAckedHoldState.delete(synthKey);
2160
+ deleteHoldState(synthKey, mesh.id);
2031
2161
  LOG.info('MeshReconcile', `Worker-emit priority: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) has a real terminal completion already queued — yielding synth to the worker's own emit`);
2032
2162
  continue;
2033
2163
  }
@@ -2070,7 +2200,7 @@ async function reconcileUnterminatedDirectDispatches(
2070
2200
  // turn); it stays as a final live-state guard at synth-commit time.
2071
2201
  const reprobeStatus = await reprobeWorkerStatus(components, { isLocalNode, nodeDaemonId, readArgs });
2072
2202
  if (reprobeStatus && reprobeStatus !== 'idle') {
2073
- inFlightAckedHoldState.delete(synthKey);
2203
+ deleteHoldState(synthKey, mesh.id);
2074
2204
  LOG.info('MeshReconcile', `Live re-probe defer: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read '${reprobeStatus}' at synth-commit time — worker resumed generating; deferring synth to a later tick`);
2075
2205
  continue;
2076
2206
  }
@@ -21,6 +21,19 @@ function safeMeshId(meshId: string): string {
21
21
  return meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
22
22
  }
23
23
 
24
+ // T2 (B2b): a persisted acked-hold record for one in-flight direct dispatch. The
25
+ // reconcile loop keeps a Map cache of these but this row is the SSOT so the hold
26
+ // survives a daemon restart. See the mesh_inflight_hold table comment.
27
+ export interface MeshInflightHoldRow {
28
+ taskId: string;
29
+ meshId: string | null;
30
+ holdReason: string | null;
31
+ heldAt: number | null;
32
+ firstIdleSinceAck: number | null;
33
+ readFailureCount: number | null;
34
+ updatedAt: number | null;
35
+ }
36
+
24
37
  function legacyQueuePath(meshId: string): string {
25
38
  return join(getLedgerDir(), `${safeMeshId(meshId)}.queue.json`);
26
39
  }
@@ -280,7 +293,18 @@ export class MeshRuntimeStore {
280
293
  fingerprint TEXT,
281
294
  queued_at INTEGER NOT NULL,
282
295
  drained INTEGER NOT NULL DEFAULT 0,
283
- drained_at INTEGER
296
+ drained_at INTEGER,
297
+ -- v2 protocol envelope (B2a). All nullable so pre-v2 rows and events
298
+ -- emitted before a coordinator identity is known coexist as v1. The
299
+ -- authoritative copy of each also rides inside the payload column; these
300
+ -- columns exist for queryable idempotency (event_id) and scope-based drain
301
+ -- filtering without JSON-parsing every row. dispatched_by / intended_for
302
+ -- hold the JSON-serialized CoordinatorIdentity.
303
+ protocol_version TEXT,
304
+ event_id TEXT,
305
+ scope TEXT,
306
+ dispatched_by TEXT,
307
+ intended_for TEXT
284
308
  );
285
309
 
286
310
  CREATE INDEX IF NOT EXISTS idx_mesh_pending_events_mesh_drained
@@ -316,6 +340,38 @@ export class MeshRuntimeStore {
316
340
  mesh_id TEXT PRIMARY KEY,
317
341
  cursor INTEGER NOT NULL DEFAULT 0
318
342
  );
343
+
344
+ -- T2 (B2b): persistent acked-hold state for in-flight direct dispatches.
345
+ -- The reconcile loop's PHASE-4 acked-hold (death-consequence counter,
346
+ -- fast-track idle streak, live-confirmed flag) used to live only in a
347
+ -- process-local Map (mesh-reconcile-loop.ts inFlightAckedHoldState), so a
348
+ -- daemon restart lost it — re-opening the door to the duplicate-emit / drop
349
+ -- window that the PHASE-4 transcript synth backstop then had to correct after
350
+ -- the fact. Persisting it lets the state survive a restart: the loop
351
+ -- rehydrates the Map from this table on first touch and stays read-through /
352
+ -- write-through against it thereafter. Keyed by task_id (one hold per
353
+ -- in-flight dispatch); mesh_id is carried for per-mesh listing / prune.
354
+ -- hold_reason — 'live' once a conclusive read confirmed the session
355
+ -- reachable since the ack, else 'unconfirmed' (drives
356
+ -- the death-backstop's liveConfirmedSinceAck gate).
357
+ -- held_at — ms epoch the hold row was first created.
358
+ -- first_idle_since_ack — ms epoch of the FIRST tick in the current continuous
359
+ -- idle-with-final-assistant run (fast-track streak); NULL
360
+ -- when the streak is broken / not yet started.
361
+ -- read_failure_count — consecutive read_chat failures since the last
362
+ -- conclusive read (death backstop (a)).
363
+ CREATE TABLE IF NOT EXISTS mesh_inflight_hold (
364
+ task_id TEXT PRIMARY KEY,
365
+ mesh_id TEXT,
366
+ hold_reason TEXT,
367
+ held_at INTEGER,
368
+ first_idle_since_ack INTEGER,
369
+ read_failure_count INTEGER,
370
+ updated_at INTEGER
371
+ );
372
+
373
+ CREATE INDEX IF NOT EXISTS idx_mesh_inflight_hold_mesh
374
+ ON mesh_inflight_hold(mesh_id);
319
375
  `);
320
376
  this.migrateMeshIsolationColumns();
321
377
  }
@@ -383,6 +439,26 @@ export class MeshRuntimeStore {
383
439
  if (!missionCols.has('source')) {
384
440
  this.db.exec(`ALTER TABLE mesh_missions ADD COLUMN source TEXT`);
385
441
  }
442
+
443
+ // 4. mesh_pending_events v2 envelope columns (B2a). A pre-v2 DB has the
444
+ // table (CREATE IF NOT EXISTS is a no-op) without these columns, so add
445
+ // each missing one. All nullable — legacy rows read back as v1 events
446
+ // (protocol_version NULL) with no reader change. Idempotent: the column
447
+ // check short-circuits once present, and every ADD COLUMN is guarded.
448
+ const pendingCols = this.tableColumns('mesh_pending_events');
449
+ for (const col of ['protocol_version', 'event_id', 'scope', 'dispatched_by', 'intended_for'] as const) {
450
+ if (!pendingCols.has(col)) {
451
+ this.db.exec(`ALTER TABLE mesh_pending_events ADD COLUMN ${col} TEXT`);
452
+ }
453
+ }
454
+ // Idempotency index on event_id (partial: only stamped v2 rows). Created
455
+ // unconditionally — IF NOT EXISTS makes it a no-op once present, and the
456
+ // event_id column is guaranteed to exist by the loop above.
457
+ this.db.exec(`
458
+ CREATE INDEX IF NOT EXISTS idx_mesh_pending_events_event_id
459
+ ON mesh_pending_events(mesh_id, event_id)
460
+ WHERE event_id IS NOT NULL
461
+ `);
386
462
  } catch (err: any) {
387
463
  // Best-effort: a failed isolation migration must not brick the store. The
388
464
  // CREATE-TABLE definitions above already carry the new schema for fresh DBs;
@@ -650,6 +726,78 @@ export class MeshRuntimeStore {
650
726
  });
651
727
  }
652
728
 
729
+ // ── Acked-Hold State (T2 / B2b) ──────────────────────────────────────────
730
+ //
731
+ // Persistent mirror of the reconcile loop's inFlightAckedHoldState Map. Keyed
732
+ // by task_id (one in-flight dispatch = one hold). These are plain read/write/
733
+ // delete/list accessors; the read-through/write-through cache and the restart
734
+ // rehydrate live in mesh-reconcile-loop.ts.
735
+
736
+ private mapInflightHoldRow(r: Record<string, unknown> | undefined): MeshInflightHoldRow | null {
737
+ if (!r) return null;
738
+ return {
739
+ taskId: r.task_id as string,
740
+ meshId: (r.mesh_id as string | null) ?? null,
741
+ holdReason: (r.hold_reason as string | null) ?? null,
742
+ heldAt: (r.held_at as number | null) ?? null,
743
+ firstIdleSinceAck: (r.first_idle_since_ack as number | null) ?? null,
744
+ readFailureCount: (r.read_failure_count as number | null) ?? null,
745
+ updatedAt: (r.updated_at as number | null) ?? null,
746
+ };
747
+ }
748
+
749
+ upsertInflightHold(entry: {
750
+ taskId: string;
751
+ meshId?: string | null;
752
+ holdReason?: string | null;
753
+ heldAt?: number | null;
754
+ firstIdleSinceAck?: number | null;
755
+ readFailureCount?: number | null;
756
+ }): void {
757
+ const now = Date.now();
758
+ // Preserve held_at across an upsert (it marks when the hold was first created);
759
+ // only set it from the incoming value when the row is new. All other fields are
760
+ // overwritten with the latest state — the caller passes the full current state.
761
+ this.db.prepare(`
762
+ INSERT INTO mesh_inflight_hold
763
+ (task_id, mesh_id, hold_reason, held_at, first_idle_since_ack, read_failure_count, updated_at)
764
+ VALUES (@taskId, @meshId, @holdReason, @heldAt, @firstIdleSinceAck, @readFailureCount, @updatedAt)
765
+ ON CONFLICT(task_id) DO UPDATE SET
766
+ mesh_id = excluded.mesh_id,
767
+ hold_reason = excluded.hold_reason,
768
+ first_idle_since_ack = excluded.first_idle_since_ack,
769
+ read_failure_count = excluded.read_failure_count,
770
+ updated_at = excluded.updated_at
771
+ `).run({
772
+ taskId: entry.taskId,
773
+ meshId: entry.meshId ?? null,
774
+ holdReason: entry.holdReason ?? null,
775
+ heldAt: entry.heldAt ?? now,
776
+ firstIdleSinceAck: entry.firstIdleSinceAck ?? null,
777
+ readFailureCount: entry.readFailureCount ?? null,
778
+ updatedAt: now,
779
+ });
780
+ this.maybeCheckpointWal();
781
+ }
782
+
783
+ getInflightHold(taskId: string): MeshInflightHoldRow | null {
784
+ const row = this.db.prepare(
785
+ 'SELECT * FROM mesh_inflight_hold WHERE task_id = ?'
786
+ ).get(taskId) as Record<string, unknown> | undefined;
787
+ return this.mapInflightHoldRow(row);
788
+ }
789
+
790
+ listInflightHoldsByMesh(meshId: string): MeshInflightHoldRow[] {
791
+ const rows = this.db.prepare(
792
+ 'SELECT * FROM mesh_inflight_hold WHERE mesh_id = ?'
793
+ ).all(meshId) as Array<Record<string, unknown>>;
794
+ return rows.map(r => this.mapInflightHoldRow(r)).filter((r): r is MeshInflightHoldRow => r !== null);
795
+ }
796
+
797
+ deleteInflightHold(taskId: string): void {
798
+ this.db.prepare('DELETE FROM mesh_inflight_hold WHERE task_id = ?').run(taskId);
799
+ }
800
+
653
801
  /**
654
802
  * Count active (status='assigned') tasks on a (node, provider) combination,
655
803
  * matched by the assignedProviderType stamped on the payload at claim time.
@@ -1652,11 +1800,19 @@ export class MeshRuntimeStore {
1652
1800
  payload?: unknown;
1653
1801
  fingerprint?: string | null;
1654
1802
  queuedAt: number;
1803
+ // v2 envelope columns (B2a) — all optional so v1 callers/rows are unaffected.
1804
+ // dispatchedBy / intendedFor are pre-serialized CoordinatorIdentity JSON.
1805
+ protocolVersion?: string | null;
1806
+ eventId?: string | null;
1807
+ scope?: string | null;
1808
+ dispatchedBy?: string | null;
1809
+ intendedFor?: string | null;
1655
1810
  }): boolean {
1656
1811
  const result = this.db.prepare(
1657
1812
  `INSERT OR IGNORE INTO mesh_pending_events
1658
- (id, mesh_id, coordinator_daemon_id, event, payload, fingerprint, queued_at)
1659
- VALUES (?, ?, ?, ?, ?, ?, ?)`
1813
+ (id, mesh_id, coordinator_daemon_id, event, payload, fingerprint, queued_at,
1814
+ protocol_version, event_id, scope, dispatched_by, intended_for)
1815
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1660
1816
  ).run(
1661
1817
  event.id,
1662
1818
  event.meshId,
@@ -1665,6 +1821,11 @@ export class MeshRuntimeStore {
1665
1821
  JSON.stringify(event.payload ?? {}),
1666
1822
  event.fingerprint ?? null,
1667
1823
  event.queuedAt,
1824
+ event.protocolVersion ?? null,
1825
+ event.eventId ?? null,
1826
+ event.scope ?? null,
1827
+ event.dispatchedBy ?? null,
1828
+ event.intendedFor ?? null,
1668
1829
  );
1669
1830
  this.maybeCheckpointWal();
1670
1831
  return result.changes > 0;