@adhdev/daemon-core 0.9.82-rc.533 → 0.9.82-rc.535

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.
@@ -96,6 +96,13 @@ export declare function getActiveSessionDeliveries(meshId: string, sessionId?: s
96
96
  createdAt: string;
97
97
  updatedAt: string;
98
98
  }[];
99
+ /**
100
+ * DELIVERED-NOT-CONSUMED-REDRIVE consume path. Advance a task's delivery record(s) to a
101
+ * CONSUMED status ('acked'/'completed') by (mesh, session[, task]), INCLUDING rows already in
102
+ * 'delivered' — unlike getActiveSessionDeliveries which excludes 'delivered'. The store's
103
+ * monotonic guard only ever advances the row. Returns the number of rows advanced.
104
+ */
105
+ export declare function consumeSessionDelivery(meshId: string, sessionId: string, status: 'acked' | 'completed', taskId?: string): number;
99
106
  export declare function __clearSessionDeliveriesForTests(meshId: string): void;
100
107
  /**
101
108
  * Mark all active (queued/delivering/delivered/acked) deliveries for a session as completed or failed.
@@ -207,10 +207,35 @@ export declare class MeshRuntimeStore {
207
207
  createdAt: string;
208
208
  updatedAt: string;
209
209
  }): void;
210
+ private static readonly DELIVERY_PROGRESS_RANK;
210
211
  updateSessionDeliveryStatus(id: string, status: string, opts?: {
211
212
  lastError?: string;
212
213
  incrementAttempt?: boolean;
213
214
  }): void;
215
+ /**
216
+ * DELIVERED-NOT-CONSUMED-REDRIVE consume path. Advance a task's delivery record(s) to a
217
+ * CONSUMED status ('acked' or 'completed'), matching on mesh + session (+ taskId when the
218
+ * event names one) and INCLUDING rows already in 'delivered'/'acked'/'delivering'.
219
+ *
220
+ * The ack/terminal callers previously routed through getActiveSessionDeliveries(), whose SQL
221
+ * EXCLUDES 'delivered' — so in the normal event order (transport confirm flips 'delivered'
222
+ * BEFORE the worker's generating_started fires) the ack matched zero rows and the delivery
223
+ * was stranded 'delivered', never 'acked'. This finds the row by (mesh, session[, task])
224
+ * directly and relies on updateSessionDeliveryStatus's monotonic guard to only advance it.
225
+ * Returns the number of rows advanced.
226
+ */
227
+ consumeSessionDelivery(meshId: string, sessionId: string, status: 'acked' | 'completed', taskId?: string): number;
228
+ /**
229
+ * DELIVERED-NOT-CONSUMED-REDRIVE terminal path. Mark every OPEN delivery for a session
230
+ * (queued/delivering/delivered/acked) terminal on task completion/failure. The prior
231
+ * markSessionDeliveriesTerminal() routed through getActiveSessionDeliveries(), whose SQL
232
+ * EXCLUDES 'delivered'/'completed' — so a 'delivered' row (the common case, since the
233
+ * transport confirm flips it before the completion event) was never marked terminal and
234
+ * stayed 'delivered', keeping taskDeliveryConsumed() false and feeding the false re-drive.
235
+ * We match rows in OPEN states directly here. 'completed' advances monotonically (it is the
236
+ * top progress rank); 'failed' is an absorbing outcome written unconditionally.
237
+ */
238
+ markOpenSessionDeliveriesTerminal(meshId: string, sessionId: string, terminalStatus: 'completed' | 'failed'): number;
214
239
  getActiveSessionDeliveries(meshId: string, sessionId?: string): Array<{
215
240
  id: string;
216
241
  meshId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.533",
3
+ "version": "0.9.82-rc.535",
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.533",
51
- "@adhdev/session-host-core": "0.9.82-rc.533",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.535",
51
+ "@adhdev/session-host-core": "0.9.82-rc.535",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -1206,7 +1206,19 @@ export class CliStateEngine {
1206
1206
  const detectFn = typeof this.transport.runDetectStatus === 'function'
1207
1207
  ? () => this.transport.runDetectStatus!(snap.recentOutputBuffer)
1208
1208
  : () => this.runDetectStatus(snap);
1209
- const latestStatus = detectFn() || this.currentStatus;
1209
+ // Only a POSITIVE `generating` verdict from the live detector defers the
1210
+ // finish. A null verdict is "no cue matched", NOT "still generating" — for
1211
+ // a provider whose only idle cue is a composer placeholder (opencode's
1212
+ // `Ask anything`) that can momentarily fall out of the captured frame while
1213
+ // the TUI redraws its status chip, detectStatus returns null under the
1214
+ // manifest's `onNoMatch: preserve-last` policy. Collapsing that null to
1215
+ // `this.currentStatus` (which is `generating` while the hold is armed) made
1216
+ // the finish defer on EVERY tick, so the completion never fired and the
1217
+ // session wedged in `generating` forever even though its assistant reply had
1218
+ // already landed in native-history. Treat null as "no evidence to defer" and
1219
+ // let the idle-finish proceed; a real in-flight turn still re-reports a
1220
+ // positive `generating` here and defers as before.
1221
+ const latestStatus = detectFn();
1210
1222
  if (latestStatus === 'generating') {
1211
1223
  this.evaluateSettled(snap);
1212
1224
  return true;
@@ -254,6 +254,25 @@ export function getActiveSessionDeliveries(meshId: string, sessionId?: string) {
254
254
  }
255
255
  }
256
256
 
257
+ /**
258
+ * DELIVERED-NOT-CONSUMED-REDRIVE consume path. Advance a task's delivery record(s) to a
259
+ * CONSUMED status ('acked'/'completed') by (mesh, session[, task]), INCLUDING rows already in
260
+ * 'delivered' — unlike getActiveSessionDeliveries which excludes 'delivered'. The store's
261
+ * monotonic guard only ever advances the row. Returns the number of rows advanced.
262
+ */
263
+ export function consumeSessionDelivery(
264
+ meshId: string,
265
+ sessionId: string,
266
+ status: 'acked' | 'completed',
267
+ taskId?: string,
268
+ ): number {
269
+ try {
270
+ return MeshRuntimeStore.getInstance().consumeSessionDelivery(meshId, sessionId, status, taskId);
271
+ } catch {
272
+ return 0;
273
+ }
274
+ }
275
+
257
276
  // MESH-COMPLEXITY-AUDIT Part 8-2: the completion-conflict diagnostic
258
277
  // (recordCompletionConflict / getRecentCompletionConflicts, backed by
259
278
  // mesh_completion_conflicts) was dropped. It recorded WHICH task lost a
@@ -276,9 +295,10 @@ export function markSessionDeliveriesTerminal(
276
295
  terminalStatus: 'completed' | 'failed',
277
296
  ): void {
278
297
  try {
279
- const active = MeshRuntimeStore.getInstance().getActiveSessionDeliveries(meshId, sessionId);
280
- for (const delivery of active) {
281
- MeshRuntimeStore.getInstance().updateSessionDeliveryStatus(delivery.id, terminalStatus);
282
- }
298
+ // Route through markOpenSessionDeliveriesTerminal, which matches OPEN rows including
299
+ // 'delivered' getActiveSessionDeliveries EXCLUDES 'delivered' and would silently
300
+ // leave the common (already-delivered) row un-terminated, keeping taskDeliveryConsumed()
301
+ // false and feeding the delivered_not_consumed_redrive false re-drive.
302
+ MeshRuntimeStore.getInstance().markOpenSessionDeliveriesTerminal(meshId, sessionId, terminalStatus);
283
303
  } catch { /* best-effort */ }
284
304
  }
@@ -5,7 +5,7 @@ import { LOG } from '../logging/logger.js';
5
5
  import { appendLedgerEntry, buildTaskCompletionEvidence, getSessionRecoveryContext, isIntentionalCleanupStopEntry, readLedgerEntries } from './mesh-ledger.js';
6
6
  import type { SessionRecoveryContext } from './mesh-ledger.js';
7
7
  import { updateSessionTaskStatus, enqueueTask, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, getActiveDirectDispatches, hasPendingDependents, getQueue } from './mesh-work-queue.js';
8
- import { markSessionDeliveriesTerminal, updateSessionDeliveryStatus } from './mesh-delivery-policy.js';
8
+ import { markSessionDeliveriesTerminal, updateSessionDeliveryStatus, consumeSessionDelivery } from './mesh-delivery-policy.js';
9
9
  import { MeshRuntimeStore, pruneMeshRuntimeRetention } from './mesh-runtime-store.js';
10
10
  import { maybeInjectIdleActiveMissionReminder } from './mesh-idle-reminder.js';
11
11
  import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, prunePendingMeshCoordinatorEventsRetention, readV2EnvelopeFromWire, type PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
@@ -1273,16 +1273,14 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1273
1273
  updateDirectDispatchStatus(args.meshId, sessionId, 'acked', soleTaskId);
1274
1274
  }
1275
1275
  }
1276
- const activeDeliveries = ((): { id: string; taskId: string | null }[] => {
1277
- try { return MeshRuntimeStore.getInstance().getActiveSessionDeliveries(args.meshId, sessionId); }
1278
- catch { return []; }
1279
- })();
1280
- const deliveriesToAck = startedTaskId
1281
- ? activeDeliveries.filter(d => d.taskId === startedTaskId)
1282
- : activeDeliveries;
1283
- for (const d of deliveriesToAck) {
1284
- updateSessionDeliveryStatus(d.id, 'acked');
1285
- }
1276
+ // DELIVERED-NOT-CONSUMED-REDRIVE: ack the delivery via consumeSessionDelivery, which
1277
+ // matches rows INCLUDING 'delivered'. The prior path filtered getActiveSessionDeliveries
1278
+ // whose SQL EXCLUDES 'delivered' — so in the normal event order (transport confirm
1279
+ // flips 'delivered' BEFORE generating_started fires) the ack matched zero rows and the
1280
+ // delivery was stranded 'delivered', never reaching the 'acked' consume signal that
1281
+ // taskDeliveryConsumed() keys on. The store's monotonic guard only advances the row.
1282
+ // With a named taskId, key on (mesh, task); otherwise fall back to the whole session.
1283
+ consumeSessionDelivery(args.meshId, sessionId, 'acked', startedTaskId);
1286
1284
  }
1287
1285
  } else if (args.event === 'agent:stopped') {
1288
1286
  const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
@@ -710,9 +710,19 @@ const RECLAIM_UNKNOWN_GRACE_TICKS = 3;
710
710
  // completed/reclaimed/claimed-elsewhere row's counter is dropped (no unbounded growth).
711
711
  const deliveredNoTurnUnknownStreak = new Map<string, number>();
712
712
 
713
- // Test hook: clear the delivered-no-turn UNKNOWN streak between cases.
713
+ // DELIVERED-NOT-CONSUMED-REDRIVE (fix d): the SHORT-grace re-drive (delivered-but-unconsumed,
714
+ // 25s window) previously reclaimed on a SINGLE non-GENERATING tick — and a REMOTE worker's local
715
+ // busy verdict is UNKNOWN, not GENERATING, so a genuinely-mid-turn remote worker whose ack merely
716
+ // hadn't propagated yet was torn off its task and the SAME prompt re-injected. Give the short path
717
+ // the same bounded consecutive-UNKNOWN grace the long delivered-no-turn path uses: only an
718
+ // IDLE_CONFIRMED verdict (positive LOCAL evidence the session is present-and-idle) re-drives
719
+ // immediately; UNKNOWN accrues a streak and re-drives only after RECLAIM_UNKNOWN_GRACE_TICKS.
720
+ const deliveredUnconsumedUnknownStreak = new Map<string, number>();
721
+
722
+ // Test hook: clear the delivered-no-turn UNKNOWN streaks between cases.
714
723
  export function __resetReclaimUnknownStreakForTests(): void {
715
724
  deliveredNoTurnUnknownStreak.clear();
725
+ deliveredUnconsumedUnknownStreak.clear();
716
726
  }
717
727
 
718
728
  // PHASE 2.5 — assigned-stranded dispatch watchdog (Bug B). claimNextTask atomically
@@ -739,6 +749,9 @@ function recoverStrandedAssignedDispatches(components: DaemonComponents, meshId:
739
749
  for (const key of [...deliveredNoTurnUnknownStreak.keys()]) {
740
750
  if (key.startsWith(meshKeyPrefix) && !assignedKeys.has(key)) deliveredNoTurnUnknownStreak.delete(key);
741
751
  }
752
+ for (const key of [...deliveredUnconsumedUnknownStreak.keys()]) {
753
+ if (key.startsWith(meshKeyPrefix) && !assignedKeys.has(key)) deliveredUnconsumedUnknownStreak.delete(key);
754
+ }
742
755
  for (const row of assigned) {
743
756
  const dispatchedAtMs = Date.parse(row.dispatchTimestamp ?? '');
744
757
  if (!Number.isFinite(dispatchedAtMs)) continue; // no dispatch ts → can't age it
@@ -774,15 +787,42 @@ function recoverStrandedAssignedDispatches(components: DaemonComponents, meshId:
774
787
  updateTaskStatus(meshId, row.id, status);
775
788
  continue;
776
789
  }
790
+ const shortStreakKey = `${meshId}::${row.id}`;
777
791
  const verdict = row.assignedSessionId
778
792
  ? resolveSessionBusyVerdict(components, row.assignedSessionId)
779
793
  : 'IDLE_CONFIRMED'; // no session bound → nothing live generating to protect
780
- if (verdict !== 'GENERATING') {
794
+ // GENERATING demonstrably alive: never re-drive, reset the grace.
795
+ // IDLE_CONFIRMED → positive LOCAL evidence the session is present-and-idle: re-drive now.
796
+ // UNKNOWN → remote / gone / id-form-skewed session: DEFER. A remote worker whose ack
797
+ // merely hasn't propagated reads UNKNOWN here — reclaiming on a single UNKNOWN tick
798
+ // tears a live remote worker off its task and re-injects the same prompt (the exact
799
+ // delivered_not_consumed_redrive symptom). Accrue a bounded consecutive-UNKNOWN streak
800
+ // and only re-drive after RECLAIM_UNKNOWN_GRACE_TICKS, matching the long path.
801
+ if (verdict === 'GENERATING') {
802
+ deliveredUnconsumedUnknownStreak.delete(shortStreakKey);
803
+ } else {
804
+ if (verdict === 'IDLE_CONFIRMED') {
805
+ deliveredUnconsumedUnknownStreak.delete(shortStreakKey);
806
+ } else {
807
+ const streak = (deliveredUnconsumedUnknownStreak.get(shortStreakKey) ?? 0) + 1;
808
+ deliveredUnconsumedUnknownStreak.set(shortStreakKey, streak);
809
+ if (streak < RECLAIM_UNKNOWN_GRACE_TICKS) {
810
+ traceMeshEventDrop('short_redrive_deferred_unknown_verdict', {
811
+ taskId: row.id,
812
+ sessionId: row.assignedSessionId,
813
+ nodeId: row.assignedNodeId,
814
+ meshId,
815
+ event: 'agent:generating_started',
816
+ }, `unknown ${streak}/${RECLAIM_UNKNOWN_GRACE_TICKS}`);
817
+ continue;
818
+ }
819
+ }
781
820
  const redriven = reclaimStrandedAssignedTask(meshId, row.id, {
782
821
  reason: 'delivered_not_consumed_redrive',
783
822
  ageMs,
784
823
  });
785
824
  if (redriven) {
825
+ deliveredUnconsumedUnknownStreak.delete(shortStreakKey);
786
826
  LOG.warn('MeshReconcile', `Re-drove delivered-but-unconsumed task ${row.id} on mesh ${meshId} `
787
827
  + `(node=${row.assignedNodeId ?? '?'} session=${row.assignedSessionId ?? '?'}, delivered but no `
788
828
  + `generating_started in ${Math.round(ageMs / 1000)}s, verdict ${verdict} → ${redriven.status})`);
@@ -1429,21 +1429,123 @@ export class MeshRuntimeStore {
1429
1429
  this.maybeCheckpointWal();
1430
1430
  }
1431
1431
 
1432
+ // DELIVERED-NOT-CONSUMED-REDRIVE monotonic FSM: the forward-progress lifecycle of a
1433
+ // delivery is a strictly increasing rank — a status may only advance, never regress.
1434
+ // The redrive bug was a NON-monotonic FSM: the transport-confirm callback
1435
+ // (mesh-queue-assignment :384) writes 'delivered' unconditionally by PK, so when the
1436
+ // worker's agent:generating_started raced AHEAD of the confirm and already flipped the
1437
+ // row 'delivering'→'acked', the late confirm CLOBBERED 'acked' back to 'delivered'.
1438
+ // taskDeliveryConsumed() (which keys on 'acked'/'completed') then read false forever,
1439
+ // and the short-grace re-drive re-opened an already-consumed task. Enforcing the rank
1440
+ // ordering here makes the two event orders converge on the same monotone terminal state
1441
+ // regardless of arrival order, so a late confirm can never demote a consumed delivery.
1442
+ // 'failed'/'expired'/'cancelled' are absorbing OUTCOMES, not progress ranks — they are
1443
+ // always allowed (a genuine dispatch failure must be recordable even from 'acked').
1444
+ private static readonly DELIVERY_PROGRESS_RANK: Record<string, number> = {
1445
+ queued: 0,
1446
+ delivering: 1,
1447
+ delivered: 2,
1448
+ acked: 3,
1449
+ completed: 4,
1450
+ };
1451
+
1432
1452
  updateSessionDeliveryStatus(id: string, status: string, opts?: { lastError?: string; incrementAttempt?: boolean }): void {
1433
1453
  const now = new Date().toISOString();
1434
1454
  if (opts?.incrementAttempt) {
1455
+ // Retry/requeue path (transport failure → 'failed', or an explicit re-queue): this is
1456
+ // the deliberate reset signal, NOT the racing progress writes that cause the clobber, so
1457
+ // it is exempt from the monotonic guard and always applies (preserves attempt_count
1458
+ // bookkeeping and the failure ledger). The clobber bug lives only in the plain
1459
+ // progress write below.
1435
1460
  this.db.prepare(`
1436
1461
  UPDATE mesh_session_delivery
1437
1462
  SET status = @status, last_error = @lastError, attempt_count = attempt_count + 1, updated_at = @updatedAt
1438
1463
  WHERE id = @id
1439
1464
  `).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now });
1440
- } else {
1465
+ return;
1466
+ }
1467
+ // Monotonic guard for forward-progress statuses: a plain status write may ADVANCE or
1468
+ // rewrite the SAME rank, but NEVER regress to a strictly-lower rank. This is what stops the
1469
+ // late transport-confirm ('delivered', rank 2) from clobbering an already-consumed row
1470
+ // ('acked', rank 3): the `@targetRank >= current` predicate fetches zero rows for 3→2, so
1471
+ // 'acked' survives. Absorbing failure outcomes (failed/expired/cancelled) have no rank and
1472
+ // are written unconditionally.
1473
+ const targetRank = MeshRuntimeStore.DELIVERY_PROGRESS_RANK[status];
1474
+ if (targetRank === undefined) {
1441
1475
  this.db.prepare(`
1442
1476
  UPDATE mesh_session_delivery
1443
1477
  SET status = @status, last_error = @lastError, updated_at = @updatedAt
1444
1478
  WHERE id = @id
1445
1479
  `).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now });
1480
+ return;
1481
+ }
1482
+ // Absorbing failure states (failed/expired/cancelled) map to rank 99 so no progress write
1483
+ // (max rank 4) can ever resurrect a dead delivery.
1484
+ this.db.prepare(`
1485
+ UPDATE mesh_session_delivery
1486
+ SET status = @status, last_error = @lastError, updated_at = @updatedAt
1487
+ WHERE id = @id AND (@targetRank >= CASE status
1488
+ WHEN 'queued' THEN 0 WHEN 'delivering' THEN 1 WHEN 'delivered' THEN 2
1489
+ WHEN 'acked' THEN 3 WHEN 'completed' THEN 4 ELSE 99 END)
1490
+ `).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now, targetRank });
1491
+ }
1492
+
1493
+ /**
1494
+ * DELIVERED-NOT-CONSUMED-REDRIVE consume path. Advance a task's delivery record(s) to a
1495
+ * CONSUMED status ('acked' or 'completed'), matching on mesh + session (+ taskId when the
1496
+ * event names one) and INCLUDING rows already in 'delivered'/'acked'/'delivering'.
1497
+ *
1498
+ * The ack/terminal callers previously routed through getActiveSessionDeliveries(), whose SQL
1499
+ * EXCLUDES 'delivered' — so in the normal event order (transport confirm flips 'delivered'
1500
+ * BEFORE the worker's generating_started fires) the ack matched zero rows and the delivery
1501
+ * was stranded 'delivered', never 'acked'. This finds the row by (mesh, session[, task])
1502
+ * directly and relies on updateSessionDeliveryStatus's monotonic guard to only advance it.
1503
+ * Returns the number of rows advanced.
1504
+ */
1505
+ consumeSessionDelivery(meshId: string, sessionId: string, status: 'acked' | 'completed', taskId?: string): number {
1506
+ const rows = this.db.prepare(
1507
+ taskId
1508
+ ? `SELECT id, session_id FROM mesh_session_delivery
1509
+ WHERE mesh_id = ? AND task_id = ?
1510
+ AND status IN ('queued','delivering','delivered','acked')`
1511
+ : `SELECT id, session_id FROM mesh_session_delivery
1512
+ WHERE mesh_id = ? AND session_id = ?
1513
+ AND status IN ('queued','delivering','delivered','acked')`,
1514
+ ).all(meshId, taskId ?? sessionId) as Array<{ id: string; session_id: string | null }>;
1515
+ // Filter session membership in JS with the trimming equivalence predicate (mirrors
1516
+ // findAssignedBySession): a taskId match must still belong to this session, and the
1517
+ // session-only match already selected by column may carry serialization skew.
1518
+ let advanced = 0;
1519
+ for (const r of rows) {
1520
+ if (!sessionIdsEquivalent(r.session_id ?? undefined, sessionId)) continue;
1521
+ this.updateSessionDeliveryStatus(r.id, status);
1522
+ advanced++;
1523
+ }
1524
+ return advanced;
1525
+ }
1526
+
1527
+ /**
1528
+ * DELIVERED-NOT-CONSUMED-REDRIVE terminal path. Mark every OPEN delivery for a session
1529
+ * (queued/delivering/delivered/acked) terminal on task completion/failure. The prior
1530
+ * markSessionDeliveriesTerminal() routed through getActiveSessionDeliveries(), whose SQL
1531
+ * EXCLUDES 'delivered'/'completed' — so a 'delivered' row (the common case, since the
1532
+ * transport confirm flips it before the completion event) was never marked terminal and
1533
+ * stayed 'delivered', keeping taskDeliveryConsumed() false and feeding the false re-drive.
1534
+ * We match rows in OPEN states directly here. 'completed' advances monotonically (it is the
1535
+ * top progress rank); 'failed' is an absorbing outcome written unconditionally.
1536
+ */
1537
+ markOpenSessionDeliveriesTerminal(meshId: string, sessionId: string, terminalStatus: 'completed' | 'failed'): number {
1538
+ const rows = this.db.prepare(
1539
+ `SELECT id, session_id FROM mesh_session_delivery
1540
+ WHERE mesh_id = ? AND status IN ('queued','delivering','delivered','acked')`,
1541
+ ).all(meshId) as Array<{ id: string; session_id: string | null }>;
1542
+ let marked = 0;
1543
+ for (const r of rows) {
1544
+ if (!sessionIdsEquivalent(r.session_id ?? undefined, sessionId)) continue;
1545
+ this.updateSessionDeliveryStatus(r.id, terminalStatus);
1546
+ marked++;
1446
1547
  }
1548
+ return marked;
1447
1549
  }
1448
1550
 
1449
1551
  getActiveSessionDeliveries(meshId: string, sessionId?: string): Array<{
@@ -1339,13 +1339,24 @@ export class ProviderLoader {
1339
1339
  candidates.push(path.join(providerDir, 'specs', 'default.json'));
1340
1340
  candidates.push(path.join(providerDir, 'spec.json'));
1341
1341
  const specPath = candidates.find((p: string) => fs.existsSync(p));
1342
+ // native_history block, resolved from either the separate spec file
1343
+ // (snake_case `native_history`) or — for v1-manifest-only providers that
1344
+ // ship no specs/*.json — the inline camelCase `nativeHistory` on the
1345
+ // manifest itself. The separate spec file wins when both exist. Without
1346
+ // the v1-manifest fallback, a provider whose ONLY declaration is an
1347
+ // inline `nativeHistory.source` (e.g. opencode's sqlite source) never got
1348
+ // its `scripts.readNativeHistory` wired: the whole block was gated on
1349
+ // `specPath`, so read_chat returned native-unavailable, the assistant
1350
+ // reply (only in the on-disk store, never in the PTY snapshot) was
1351
+ // dropped, providerSessionId stayed null, and the session wedged in
1352
+ // `generating` because no native completion evidence ever arrived.
1353
+ let nh: any | undefined;
1342
1354
  if (specPath) {
1343
1355
  // Hand the resolved spec path off to route.ts via a hidden field
1344
1356
  // so the routing layer doesn't have to repeat the candidate walk.
1345
1357
  (resolved as any)._resolvedSpecPath = specPath;
1346
1358
  // Extract control_bar + native_history directly from the JSON header.
1347
1359
  let specControls: any[] | undefined;
1348
- let nh: any | undefined;
1349
1360
  try {
1350
1361
  const rawSpec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
1351
1362
  specControls = rawSpec.control_bar;
@@ -1382,44 +1393,56 @@ export class ProviderLoader {
1382
1393
  }
1383
1394
  }
1384
1395
  }
1385
- if (nh) {
1386
- let reader: ((input: any) => any) | null = null;
1387
- let format = 'spec';
1388
-
1389
- if (nh.source) {
1390
- format = `spec-${nh.source.kind}`;
1391
- reader = (input: any) => executeNativeHistory(nh, input);
1392
- } else if (nh.override_path) {
1393
- const overrideFile = path.resolve(providerDir, nh.override_path);
1394
- if (fs.existsSync(overrideFile)) {
1395
- try {
1396
- registerProviderScriptRootSafely(path.dirname(path.dirname(providerDir)));
1397
- delete require.cache[require.resolve(overrideFile)];
1398
- // eslint-disable-next-line @typescript-eslint/no-var-requires
1399
- const mod = require(overrideFile);
1400
- const fn = typeof mod === 'function' ? mod : (mod && typeof mod.default === 'function' ? mod.default : null);
1401
- if (fn) {
1402
- format = 'spec-override';
1403
- reader = (input: any) => fn(input);
1404
- }
1405
- } catch { /* fall through — leave native unavailable */ }
1406
- }
1407
- } else if (nh.reader) {
1408
- const dispatch = createNativeHistoryDispatcher(nh.reader as ReaderId);
1409
- format = nh.reader;
1410
- reader = (input: any) => dispatch(input);
1396
+ }
1397
+ // Fall back to the v1 manifest's inline `nativeHistory` (camelCase) when
1398
+ // no separate spec file provided a `native_history` block. Only treat it
1399
+ // as a declarative reader source when it actually carries source/
1400
+ // override_path/reader — a bare `nativeHistory` marker that only names
1401
+ // `scripts.readSession` (claude/codex/antigravity, whose real reader is
1402
+ // wired from their specs/*.json) must not be mistaken for one.
1403
+ if (!nh) {
1404
+ const inlineNh = (base as any)?.nativeHistory || (resolved as any)?.nativeHistory;
1405
+ if (inlineNh && (inlineNh.source || inlineNh.override_path || inlineNh.reader)) {
1406
+ nh = inlineNh;
1407
+ }
1408
+ }
1409
+ if (nh) {
1410
+ let reader: ((input: any) => any) | null = null;
1411
+ let format = 'spec';
1412
+
1413
+ if (nh.source) {
1414
+ format = `spec-${nh.source.kind}`;
1415
+ reader = (input: any) => executeNativeHistory(nh, input);
1416
+ } else if (nh.override_path) {
1417
+ const overrideFile = path.resolve(providerDir, nh.override_path);
1418
+ if (fs.existsSync(overrideFile)) {
1419
+ try {
1420
+ registerProviderScriptRootSafely(path.dirname(path.dirname(providerDir)));
1421
+ delete require.cache[require.resolve(overrideFile)];
1422
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1423
+ const mod = require(overrideFile);
1424
+ const fn = typeof mod === 'function' ? mod : (mod && typeof mod.default === 'function' ? mod.default : null);
1425
+ if (fn) {
1426
+ format = 'spec-override';
1427
+ reader = (input: any) => fn(input);
1428
+ }
1429
+ } catch { /* fall through — leave native unavailable */ }
1411
1430
  }
1431
+ } else if (nh.reader) {
1432
+ const dispatch = createNativeHistoryDispatcher(nh.reader as ReaderId);
1433
+ format = nh.reader;
1434
+ reader = (input: any) => dispatch(input);
1435
+ }
1412
1436
 
1413
- if (reader) {
1414
- resolved.scripts = { ...(resolved.scripts || {}) };
1415
- (resolved.scripts as any).readNativeHistory = reader;
1416
- (resolved as any).nativeHistory = {
1417
- format,
1418
- watchPath: undefined,
1419
- scripts: { readSession: 'readNativeHistory' },
1420
- mode: 'native-source',
1421
- };
1422
- }
1437
+ if (reader) {
1438
+ resolved.scripts = { ...(resolved.scripts || {}) };
1439
+ (resolved.scripts as any).readNativeHistory = reader;
1440
+ (resolved as any).nativeHistory = {
1441
+ format,
1442
+ watchPath: undefined,
1443
+ scripts: { readSession: 'readNativeHistory' },
1444
+ mode: 'native-source',
1445
+ };
1423
1446
  }
1424
1447
  }
1425
1448
  } catch {