@adhdev/daemon-core 0.9.82-rc.406 → 0.9.82-rc.407

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.406",
3
+ "version": "0.9.82-rc.407",
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",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.406",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.407",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -115,7 +115,40 @@ function resolveReconcileIntervalMs(): number {
115
115
  // Keyed by `${meshId}::${taskId}`. The map is pruned each PHASE-4 pass to the set of currently
116
116
  // active dispatches, so a completed/pruned task's counter is dropped (no unbounded growth).
117
117
  const REQUIRED_CONSECUTIVE_IDLE_TICKS_FOR_INFLIGHT_SYNTH = 2;
118
- const inFlightIdleObservationCounts = new Map<string, number>();
118
+
119
+ // R4e (RECONCILE-SYNTH-PREEMPTS-COMPLETION, time hardening). The consecutive-tick guard above is a
120
+ // tick COUNT; at the 4s cadence it spans only ~8s, which a long worker turn can straddle with a
121
+ // mid-turn idle window (a CLI PTY inter-tool-call settle, or the final assistant text already
122
+ // rendered while the turn's generating_completed lifecycle close still lags). The worker's 5s
123
+ // generating heartbeat means an ~8s idle window is barely over one heartbeat gap, so the tick count
124
+ // alone let a 53s-turn synth fire ~11s BEFORE the worker's real completion emit (R4e live case). We
125
+ // add two TIME hurdles the tick-count cannot express, BOTH finite so a truly-dead worker that never
126
+ // emits is still eventually synthesized (notification-miss stays 0 — only DEFERRED, never dropped):
127
+ // - MIN_IDLE_SETTLE_MS: the worker must have read idle for at least this long since its FIRST idle
128
+ // observation. Comfortably exceeds the 5s heartbeat gap so a transient mid-turn idle window
129
+ // cannot satisfy it; a genuinely-settled or dead session keeps reading idle and crosses it.
130
+ // - ACKED_TURN_SETTLE_MS: an `acked` (generating_started) dispatch is never synthesized within
131
+ // this window of its ack (dispatch.updatedAt = the generating_started status flip), so a turn
132
+ // that only just started is never completed off an early idle blip.
133
+ // Both are read at call time (not module-const) so tests can tune them via env per case.
134
+ function resolveTunedReconcileMs(envName: string, def: number, min: number, max: number): number {
135
+ const raw = readNonEmptyString(process.env[envName]);
136
+ if (raw) {
137
+ const parsed = Number.parseInt(raw, 10);
138
+ if (Number.isFinite(parsed) && parsed >= min && parsed <= max) return parsed;
139
+ }
140
+ return def;
141
+ }
142
+ function resolveMinIdleSettleMs(): number {
143
+ return resolveTunedReconcileMs('MESH_INFLIGHT_MIN_IDLE_SETTLE_MS', 16_000, 0, 120_000);
144
+ }
145
+ function resolveAckedTurnSettleMs(): number {
146
+ return resolveTunedReconcileMs('MESH_INFLIGHT_ACKED_TURN_SETTLE_MS', 20_000, 0, 180_000);
147
+ }
148
+
149
+ // Per-task in-flight idle debounce: the consecutive idle-read `count` AND the timestamp of the
150
+ // FIRST idle observation `firstIdleAtMs` (for the MIN_IDLE_SETTLE_MS hurdle).
151
+ const inFlightIdleObservationCounts = new Map<string, { count: number; firstIdleAtMs: number }>();
119
152
 
120
153
  function inFlightSynthKey(meshId: string, taskId: string): string {
121
154
  return `${meshId}::${taskId}`;
@@ -1231,6 +1264,50 @@ function readChatPayloadStatus(payload: Record<string, unknown> | null): string
1231
1264
  return readNonEmptyString(payload?.status).toLowerCase();
1232
1265
  }
1233
1266
 
1267
+ // R4e fix (3): peek the pending-events queue for a REAL (worker-emitted) terminal completion
1268
+ // already queued for a task — used to yield the in-flight synth to the worker's own emit. Broad
1269
+ // peek (no daemon-id scoping) matched precisely by taskId, so a worker stamp in any daemon-id form
1270
+ // is still recognized. Best-effort: a peek failure returns false (proceed to synth — never block
1271
+ // delivery). A prior SYNTH's still-queued pending event also names this taskId, but a synth always
1272
+ // writes its terminal ledger atomically, so hasTerminalLedgerAfterDispatch downstream already
1273
+ // no-ops that case — this guard is specifically for an as-yet-unledgered worker emit in flight.
1274
+ function realTerminalEmitPendingForTask(meshId: string, taskId: string): boolean {
1275
+ let pending: readonly PendingMeshCoordinatorEvent[];
1276
+ try {
1277
+ pending = getPendingMeshCoordinatorEvents(meshId);
1278
+ } catch {
1279
+ return false;
1280
+ }
1281
+ return pending.some(e =>
1282
+ readNonEmptyString(e.metadataEvent?.taskId) === taskId
1283
+ && (e.event === 'agent:generating_completed' || e.event === 'agent:stopped'));
1284
+ }
1285
+
1286
+ // R4e fix (2): one fresh read_chat status read for the worker session, via the same local/remote
1287
+ // transport PHASE 4 uses. Returns the lowercased status, or null when the read is inconclusive
1288
+ // (transport error, success:false, no payload) — callers treat null as "no new evidence, proceed".
1289
+ async function reprobeWorkerStatus(
1290
+ components: DaemonComponents,
1291
+ args: { isLocalNode: boolean; nodeDaemonId: string; readArgs: Record<string, unknown> },
1292
+ ): Promise<string | null> {
1293
+ try {
1294
+ if (args.isLocalNode) {
1295
+ const r = await components.commandHandler.handle('read_chat', args.readArgs);
1296
+ if (r && (r as { success?: boolean }).success === false) return null;
1297
+ return readChatPayloadStatus(unwrapReadChatPayload(r));
1298
+ }
1299
+ if (components.dispatchMeshCommand) {
1300
+ const r = await components.dispatchMeshCommand(args.nodeDaemonId, 'read_chat', args.readArgs);
1301
+ const p = unwrapReadChatPayload(r);
1302
+ if (p && (p as { success?: boolean }).success === false) return null;
1303
+ return readChatPayloadStatus(p);
1304
+ }
1305
+ } catch {
1306
+ return null;
1307
+ }
1308
+ return null;
1309
+ }
1310
+
1234
1311
  // PHASE 4 helper. For every active (non-terminal) direct dispatch this daemon
1235
1312
  // hosts, confirm the worker session is idle via a read_chat and — if a final
1236
1313
  // assistant summary is present but no terminal ledger exists for that dispatch —
@@ -1314,6 +1391,7 @@ async function reconcileUnterminatedDirectDispatches(
1314
1391
  // waiting_approval session is mid-turn — synthesizing a completion now would
1315
1392
  // be wrong. (idle is the only status the MCP poll path reconciles too.)
1316
1393
  const synthKey = inFlightSynthKey(mesh.id, taskId);
1394
+ const nowMs = Date.now();
1317
1395
  if (readChatPayloadStatus(payload) !== 'idle') {
1318
1396
  // Not idle → the worker is mid-turn. Reset any partial idle streak so a single
1319
1397
  // idle blip during a long generation never accumulates toward the synth threshold.
@@ -1321,24 +1399,48 @@ async function reconcileUnterminatedDirectDispatches(
1321
1399
  continue;
1322
1400
  }
1323
1401
 
1324
- // RECONCILE-SYNTH-PREEMPTS-COMPLETION: a dispatch whose worker was OBSERVED to start
1325
- // generating (the agent:generating_started ack flipped the row to 'acked') and has no
1326
- // terminal yet is potentially still in-flight — its `idle` read here may be a transient
1327
- // mid-turn flicker, not a settled completion. Require CONSECUTIVE idle observations
1328
- // before synthesizing for such a task: a flicker clears next tick (counter reset above),
1329
- // while a genuinely-settled (completed-but-lost) or dead session reads idle every tick
1330
- // and crosses the threshold within ~one extra interval. A never-acked dispatch (worker
1331
- // never started) is exempt there is no in-flight generation to pre-empt, and its
1332
- // lost-dispatch case is still covered by the downstream grace + stale-summary guards.
1402
+ // RECONCILE-SYNTH-PREEMPTS-COMPLETION (R4e hardened): a dispatch whose worker was OBSERVED
1403
+ // to start generating (the agent:generating_started ack flipped the row to 'acked') and has
1404
+ // no terminal yet is potentially still in-flight — its `idle` read here may be a transient
1405
+ // mid-turn window, not a settled completion. Before synthesizing for such a task we require
1406
+ // ALL of: (1) CONSECUTIVE idle observations (a flicker clears next tick, counter reset
1407
+ // above); (2) at least MIN_IDLE_SETTLE_MS elapsed since the FIRST idle observation (the time
1408
+ // hurdle the ~8s tick count cannot express it would otherwise be straddled by a long
1409
+ // turn's mid-turn idle); and (3) at least ACKED_TURN_SETTLE_MS since the generating_started
1410
+ // ack, so a turn that only just started is never completed off an early idle blip. A
1411
+ // genuinely-settled (completed-but-lost) or dead session keeps reading idle and clears all
1412
+ // three within a few extra ticks, so notification-miss stays 0. A never-acked dispatch
1413
+ // (worker never started) is exempt — no in-flight generation to pre-empt; its lost-dispatch
1414
+ // case is covered by the downstream grace + stale-summary guards.
1333
1415
  if (dispatch.status === 'acked') {
1334
- const idleStreak = (inFlightIdleObservationCounts.get(synthKey) ?? 0) + 1;
1335
- inFlightIdleObservationCounts.set(synthKey, idleStreak);
1336
- if (idleStreak < REQUIRED_CONSECUTIVE_IDLE_TICKS_FOR_INFLIGHT_SYNTH) {
1337
- LOG.info('MeshReconcile', `In-flight synth hold: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read idle ${idleStreak}/${REQUIRED_CONSECUTIVE_IDLE_TICKS_FOR_INFLIGHT_SYNTH} consecutive tick(s) after generating_started — deferring completion synth until the idle settle is confirmed (guards against a mid-turn idle flicker pre-empting the real completion)`);
1416
+ const prior = inFlightIdleObservationCounts.get(synthKey);
1417
+ const firstIdleAtMs = prior?.firstIdleAtMs ?? nowMs;
1418
+ const idleStreak = (prior?.count ?? 0) + 1;
1419
+ inFlightIdleObservationCounts.set(synthKey, { count: idleStreak, firstIdleAtMs });
1420
+ const idleSettleMs = nowMs - firstIdleAtMs;
1421
+ const minIdleSettleMs = resolveMinIdleSettleMs();
1422
+ const ackedTurnSettleMs = resolveAckedTurnSettleMs();
1423
+ const ackedAtMs = Date.parse(readNonEmptyString(dispatch.updatedAt));
1424
+ const sinceAckMs = Number.isFinite(ackedAtMs) ? nowMs - ackedAtMs : Number.POSITIVE_INFINITY;
1425
+ const tickGuardMet = idleStreak >= REQUIRED_CONSECUTIVE_IDLE_TICKS_FOR_INFLIGHT_SYNTH;
1426
+ const settleGuardMet = idleSettleMs >= minIdleSettleMs;
1427
+ const ackGuardMet = sinceAckMs >= ackedTurnSettleMs;
1428
+ if (!tickGuardMet || !settleGuardMet || !ackGuardMet) {
1429
+ LOG.info('MeshReconcile', `In-flight synth hold: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) idle ${idleStreak}/${REQUIRED_CONSECUTIVE_IDLE_TICKS_FOR_INFLIGHT_SYNTH} tick(s), settle ${Math.round(idleSettleMs / 1000)}s/${Math.round(minIdleSettleMs / 1000)}s, since-ack ${Number.isFinite(sinceAckMs) ? Math.round(sinceAckMs / 1000) + 's' : '∞'}/${Math.round(ackedTurnSettleMs / 1000)}s — deferring completion synth until the worker's turn genuinely settles (guards against a mid-turn idle window pre-empting the real completion)`);
1338
1430
  continue;
1339
1431
  }
1340
1432
  }
1341
1433
 
1434
+ // R4e fix (3) — worker-emit priority. If the worker's REAL terminal emit for this task has
1435
+ // already arrived in the pending-events queue (queued for delivery to the coordinator) but
1436
+ // not yet written a terminal ledger, YIELD: let the genuine emit surface rather than racing
1437
+ // it with a synth that would win the taskId-anchored fingerprint dedup and mask it.
1438
+ if (realTerminalEmitPendingForTask(mesh.id, taskId)) {
1439
+ inFlightIdleObservationCounts.delete(synthKey);
1440
+ 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`);
1441
+ continue;
1442
+ }
1443
+
1342
1444
  const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
1343
1445
  const evidence = extractFinalAssistantSummaryEvidence(messages);
1344
1446
  if (!evidence.finalSummary) continue; // no assistant result yet — nothing to attribute
@@ -1369,6 +1471,19 @@ async function reconcileUnterminatedDirectDispatches(
1369
1471
  continue;
1370
1472
  }
1371
1473
 
1474
+ // R4e fix (2) — live re-probe immediately before committing the synth. The idle
1475
+ // observations that satisfied the settle guards above accumulated over PRIOR ticks; do one
1476
+ // fresh read right now so a worker that resumed generating since this tick's first read is
1477
+ // never falsely completed off a stale snapshot. Best-effort: an inconclusive re-probe
1478
+ // (transport error/null) falls through to the synth — we already hold a valid idle read from
1479
+ // the top of THIS tick, so a re-probe failure must not re-introduce a notification-miss.
1480
+ const reprobeStatus = await reprobeWorkerStatus(components, { isLocalNode, nodeDaemonId, readArgs });
1481
+ if (reprobeStatus && reprobeStatus !== 'idle') {
1482
+ inFlightIdleObservationCounts.delete(synthKey);
1483
+ 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`);
1484
+ continue;
1485
+ }
1486
+
1372
1487
  const providerSessionId = readNonEmptyString(payload.providerSessionId);
1373
1488
  const coordinatorDaemonId = selfIds.find(id => !!id);
1374
1489
  try {