@adhdev/daemon-core 0.9.82-rc.432 → 0.9.82-rc.433

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.
@@ -1,6 +1,26 @@
1
1
  import type { RepoMeshDaemonRole, RepoMeshHostMetadata, RepoMeshHostStatus } from '../repo-mesh-types.js';
2
2
  export declare function normalizeMeshDaemonRole(value: unknown): RepoMeshDaemonRole | undefined;
3
- export declare function resolveMeshHostStatus(mesh: unknown): RepoMeshHostStatus;
3
+ /**
4
+ * Options for resolveMeshHostStatus's read-side host-pin default.
5
+ *
6
+ * `localDaemonId` is the id of the daemon evaluating the mesh (typically
7
+ * `deps.statusInstanceId`). When the persisted `meshHost` declares `role:'host'`
8
+ * but carries NO `hostDaemonId` (the first-setup miss — a host mesh whose pin was
9
+ * never written to config), the host daemon IS this local daemon by definition, so
10
+ * we synthesize `hostDaemonId = localDaemonId` (and, when the mesh has a node
11
+ * representing this daemon, `hostNodeId`). This is the read-side default — the SSOT
12
+ * is computed from the role + the evaluating daemon's identity rather than requiring
13
+ * a config migration to backfill every already-created mesh.
14
+ *
15
+ * HARD guard: the synthesis fires ONLY for `role:'host'`. A `role:'member'` daemon
16
+ * must NEVER fill itself in as host — that would make a member falsely claim
17
+ * coordinator/queue ownership.
18
+ */
19
+ export interface ResolveMeshHostOptions {
20
+ /** Id of the daemon evaluating this mesh (e.g. deps.statusInstanceId). */
21
+ localDaemonId?: string;
22
+ }
23
+ export declare function resolveMeshHostStatus(mesh: unknown, opts?: ResolveMeshHostOptions): RepoMeshHostStatus;
4
24
  export declare function isMeshHostOwner(mesh: unknown): boolean;
5
25
  export declare function buildMeshHostRequiredFailure(mesh: unknown, operation: string): Record<string, unknown>;
6
26
  export declare function requireMeshHostQueueOwner(opts?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.432",
3
+ "version": "0.9.82-rc.433",
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.432",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.433",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -65,7 +65,11 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
65
65
  const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
66
66
  const mesh = meshRecord?.mesh;
67
67
  if (!mesh) return { success: false, error: 'Mesh not found' };
68
- const meshHost = resolveMeshHostStatus(mesh);
68
+ // Pass the evaluating daemon's id so a host mesh whose
69
+ // hostDaemonId was never persisted (HOST-MISSEED-FIRSTSETUP) gets
70
+ // pinned to THIS daemon — the dashboard then renders M4 as host
71
+ // instead of falling back to 'no host yet'.
72
+ const meshHost = resolveMeshHostStatus(mesh, { localDaemonId: ctx.deps.statusInstanceId });
69
73
 
70
74
  const refreshRequested = args?.refresh === true || args?.forceRefresh === true;
71
75
  // Compact (default) elides each mission's full goal text from the
@@ -1,3 +1,4 @@
1
+ import { daemonIdsEquivalent } from '@adhdev/mesh-shared';
1
2
  import type { RepoMeshDaemonRole, RepoMeshHostMetadata, RepoMeshHostStatus } from '../repo-mesh-types.js';
2
3
 
3
4
  function readObject(value: unknown): Record<string, unknown> | null {
@@ -12,7 +13,28 @@ export function normalizeMeshDaemonRole(value: unknown): RepoMeshDaemonRole | un
12
13
  return value === 'host' || value === 'member' ? value : undefined;
13
14
  }
14
15
 
15
- export function resolveMeshHostStatus(mesh: unknown): RepoMeshHostStatus {
16
+ /**
17
+ * Options for resolveMeshHostStatus's read-side host-pin default.
18
+ *
19
+ * `localDaemonId` is the id of the daemon evaluating the mesh (typically
20
+ * `deps.statusInstanceId`). When the persisted `meshHost` declares `role:'host'`
21
+ * but carries NO `hostDaemonId` (the first-setup miss — a host mesh whose pin was
22
+ * never written to config), the host daemon IS this local daemon by definition, so
23
+ * we synthesize `hostDaemonId = localDaemonId` (and, when the mesh has a node
24
+ * representing this daemon, `hostNodeId`). This is the read-side default — the SSOT
25
+ * is computed from the role + the evaluating daemon's identity rather than requiring
26
+ * a config migration to backfill every already-created mesh.
27
+ *
28
+ * HARD guard: the synthesis fires ONLY for `role:'host'`. A `role:'member'` daemon
29
+ * must NEVER fill itself in as host — that would make a member falsely claim
30
+ * coordinator/queue ownership.
31
+ */
32
+ export interface ResolveMeshHostOptions {
33
+ /** Id of the daemon evaluating this mesh (e.g. deps.statusInstanceId). */
34
+ localDaemonId?: string;
35
+ }
36
+
37
+ export function resolveMeshHostStatus(mesh: unknown, opts?: ResolveMeshHostOptions): RepoMeshHostStatus {
16
38
  const meshRecord = readObject(mesh);
17
39
  const raw = readObject(meshRecord?.meshHost);
18
40
  const role = normalizeMeshDaemonRole(raw?.role) ?? 'host';
@@ -23,9 +45,25 @@ export function resolveMeshHostStatus(mesh: unknown): RepoMeshHostStatus {
23
45
  canOwnQueue: role === 'host',
24
46
  defaulted: !raw,
25
47
  };
26
- const hostDaemonId = readString(raw?.hostDaemonId);
27
- const hostNodeId = readString(raw?.hostNodeId);
48
+ let hostDaemonId = readString(raw?.hostDaemonId);
49
+ let hostNodeId = readString(raw?.hostNodeId);
28
50
  const hostAddress = readString(raw?.hostAddress);
51
+ // HOST-MISSEED-FIRSTSETUP read-side default: a host mesh with no persisted
52
+ // hostDaemonId is hosted by THIS daemon (role:'host' is local-relative), so
53
+ // fill the pin from the evaluating daemon. Member daemons are never synthesized.
54
+ const localDaemonId = readString(opts?.localDaemonId);
55
+ if (role === 'host' && !hostDaemonId && localDaemonId) {
56
+ hostDaemonId = localDaemonId;
57
+ // Anchor hostNodeId to the node representing this local daemon, when present.
58
+ if (!hostNodeId && Array.isArray(meshRecord?.nodes)) {
59
+ const selfNode = (meshRecord!.nodes as unknown[]).find(n => {
60
+ const nodeDaemonId = readString(readObject(n)?.daemonId);
61
+ return nodeDaemonId ? daemonIdsEquivalent(nodeDaemonId, localDaemonId) : false;
62
+ });
63
+ const selfNodeId = readString(readObject(selfNode)?.id);
64
+ if (selfNodeId) hostNodeId = selfNodeId;
65
+ }
66
+ }
29
67
  if (hostDaemonId) normalized.hostDaemonId = hostDaemonId;
30
68
  if (hostNodeId) normalized.hostNodeId = hostNodeId;
31
69
  if (hostAddress) normalized.hostAddress = hostAddress;
@@ -183,12 +183,58 @@ function resolveAckedDeathDeadlineMs(): number {
183
183
  return resolveTunedReconcileMs('MESH_INFLIGHT_ACKED_DEATH_DEADLINE_MS', 8 * 60_000, 0, 60 * 60_000);
184
184
  }
185
185
 
186
+ // ACKED-HOLD-IDLE-OVERTRUST (transcript-completion fast-track). The indefinite acked-hold above is
187
+ // safe but SLOW: when the worker's real generating_completed emit is dropped/lost, the only thing
188
+ // that promotes the missing completion is the 8-min death backstop — even though the answer has been
189
+ // FULLY rendered in the transcript for minutes (read_chat reports idle WITH a final visible assistant
190
+ // message every ~4s). Observed live: completions surfaced 144s / 492s late, both incompatible with the
191
+ // provider's own emit ceiling (COMPLETED_FINALIZATION_MAX_WAIT_MS 30s + NATIVE_HISTORY_MESH_IDLE_SETTLE
192
+ // 4s ≈ 34s). That gap = a worker that finished, whose PTY generating→idle edge / real emit was lost,
193
+ // held hostage to the 8-min net.
194
+ //
195
+ // Fast-track: when an acked task reads idle AND a final visible assistant message is present (the same
196
+ // transcript-completion evidence PHASE 4 already requires to synth), and that idle-with-final-assistant
197
+ // state has PERSISTED for a short continuous grace, promote the synth EARLY — ahead of the 8-min
198
+ // backstop. The grace is the correctness gate: a SINGLE idle read could be a mid-turn blip (PTY
199
+ // inter-tool-call settle, or final text rendered while the next tool call is about to start), so we
200
+ // require the idle-with-final-assistant signal to hold continuously for the grace window before
201
+ // trusting it as a genuine turn-end. Any non-idle read (generating / waiting_approval), a read
202
+ // failure, or the disappearance of the final assistant message RESETS the streak — so an actively
203
+ // streaming worker that momentarily reads idle never crosses the grace.
204
+ //
205
+ // Safety: this only changes WHEN an acked synth fires (earlier), never WHETHER it is correct —
206
+ // reconcileDirectDispatchCompletionFromTranscript's hasTerminalLedgerAfterDispatch makes a real
207
+ // emit that lands later an idempotent no-op, exactly as the death-backstop synth relies on. The
208
+ // death backstop (8 min) is PRESERVED unchanged as the final net; the fast-track is a faster path in
209
+ // front of it. The grace is set ABOVE the provider's own emit ceiling (~34s) so a worker still inside
210
+ // its normal finalization window is never pre-empted — we only fast-track once enough continuous idle
211
+ // has elapsed that a live emit would already have arrived.
212
+ function resolveAckedTranscriptFastTrackGraceMs(): number {
213
+ // Default 40s — above the provider emit ceiling (30s COMPLETED_FINALIZATION_MAX_WAIT_MS + 4s
214
+ // NATIVE_HISTORY_MESH_IDLE_SETTLE ≈ 34s): a genuinely-live worker would have emitted its real
215
+ // terminal within that window, so 40s of CONTINUOUS idle-with-final-assistant means the emit was
216
+ // lost, not late. Far below the 8-min death backstop, so the fast-track is the dominant path for a
217
+ // lost emit while the backstop remains the last-resort net. Floor 0 lets tests force an immediate
218
+ // fast-track; ceiling 5min keeps a mis-set env from collapsing it into the death backstop.
219
+ return resolveTunedReconcileMs('MESH_INFLIGHT_ACKED_TRANSCRIPT_FASTTRACK_GRACE_MS', 40_000, 0, 5 * 60_000);
220
+ }
221
+
186
222
  // Per-task in-flight hold state for an acked dispatch:
187
223
  // - liveConfirmedSinceAck: we have seen at least one conclusive read (idle OR generating) since
188
224
  // the ack — proves the session is reachable, so a later read FAILURE is a genuine liveness loss
189
225
  // rather than a node that was never reachable.
190
226
  // - consecutiveReadFailures: streak of inconclusive read_chat results (death backstop (a)).
191
- const inFlightAckedHoldState = new Map<string, { liveConfirmedSinceAck: boolean; consecutiveReadFailures: number }>();
227
+ // - transcriptIdleSinceMs: the timestamp of the FIRST tick in the current continuous run of
228
+ // idle-with-final-assistant reads (ACKED-HOLD-IDLE-OVERTRUST fast-track). Cleared to undefined
229
+ // whenever the signal breaks (non-idle read, read failure, or no final assistant message), so a
230
+ // mid-turn idle blip never accumulates grace. When `now - transcriptIdleSinceMs` exceeds the
231
+ // fast-track grace the synth is promoted ahead of the death backstop.
232
+ interface AckedHoldState {
233
+ liveConfirmedSinceAck: boolean;
234
+ consecutiveReadFailures: number;
235
+ transcriptIdleSinceMs?: number;
236
+ }
237
+ const inFlightAckedHoldState = new Map<string, AckedHoldState>();
192
238
 
193
239
  function inFlightSynthKey(meshId: string, taskId: string): string {
194
240
  return `${meshId}::${taskId}`;
@@ -1612,6 +1658,8 @@ async function reconcileUnterminatedDirectDispatches(
1612
1658
  const prior = inFlightAckedHoldState.get(synthKey);
1613
1659
  const failures = (prior?.consecutiveReadFailures ?? 0) + 1;
1614
1660
  const liveConfirmedSinceAck = prior?.liveConfirmedSinceAck ?? false;
1661
+ // A read failure breaks the idle-with-final-assistant run → reset the fast-track streak
1662
+ // (transcriptIdleSinceMs cleared by omission) so it must re-accumulate from scratch.
1615
1663
  inFlightAckedHoldState.set(synthKey, { liveConfirmedSinceAck, consecutiveReadFailures: failures });
1616
1664
  if (liveConfirmedSinceAck && failures >= ACKED_DEATH_CONSECUTIVE_READ_FAILURES) {
1617
1665
  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`);
@@ -1622,8 +1670,15 @@ async function reconcileUnterminatedDirectDispatches(
1622
1670
 
1623
1671
  // Read succeeded (a conclusive idle/generating status) → the session is reachable: reset the
1624
1672
  // failure streak and mark it live-confirmed-since-ack, so a LATER read failure is recognized
1625
- // as a genuine liveness loss (backstop a) rather than a node that was never reachable.
1626
- inFlightAckedHoldState.set(synthKey, { liveConfirmedSinceAck: true, consecutiveReadFailures: 0 });
1673
+ // as a genuine liveness loss (backstop a) rather than a node that was never reachable. The
1674
+ // fast-track idle streak (transcriptIdleSinceMs) is PRESERVED across this reset — it is
1675
+ // managed below where the idle + final-assistant signal is actually evaluated.
1676
+ const priorHoldState = inFlightAckedHoldState.get(synthKey);
1677
+ inFlightAckedHoldState.set(synthKey, {
1678
+ liveConfirmedSinceAck: true,
1679
+ consecutiveReadFailures: 0,
1680
+ ...(priorHoldState?.transcriptIdleSinceMs !== undefined ? { transcriptIdleSinceMs: priorHoldState.transcriptIdleSinceMs } : {}),
1681
+ });
1627
1682
 
1628
1683
  // Only act on a session that has actually settled to idle. A generating /
1629
1684
  // waiting_approval session is mid-turn — synthesizing a completion now would
@@ -1631,7 +1686,9 @@ async function reconcileUnterminatedDirectDispatches(
1631
1686
  const nowMs = Date.now();
1632
1687
  if (readChatPayloadStatus(payload) !== 'idle') {
1633
1688
  // Not idle → the worker is genuinely mid-turn (a clear live signal). Keep the
1634
- // live-confirmed flag set (above) but otherwise just wait for the real emit.
1689
+ // live-confirmed flag set (above) but RESET the fast-track idle streak: a turn that
1690
+ // resumed generating proves the prior idle was a mid-turn blip, not a settled turn-end.
1691
+ inFlightAckedHoldState.set(synthKey, { liveConfirmedSinceAck: true, consecutiveReadFailures: 0 });
1635
1692
  continue;
1636
1693
  }
1637
1694
 
@@ -1652,15 +1709,50 @@ async function reconcileUnterminatedDirectDispatches(
1652
1709
  // A never-acked dispatch (worker never started) is exempt — no in-flight generation to
1653
1710
  // pre-empt; it keeps the first-idle-tick synth, with the downstream grace + stale-summary
1654
1711
  // guards as its backstops.
1712
+ //
1713
+ // ACKED-HOLD-IDLE-OVERTRUST: the read is idle. Extract the final-assistant evidence NOW (the
1714
+ // same signal the synth below requires) so the fast-track can gate on idle-WITH-final-assistant
1715
+ // rather than bare idle. Only when a final visible assistant message is present do we treat
1716
+ // this tick as a candidate turn-end and accumulate the fast-track grace streak; a bare idle
1717
+ // with no assistant result is the worker still warming up and resets the streak.
1718
+ const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
1719
+ const evidence = extractFinalAssistantSummaryEvidence(messages);
1720
+
1655
1721
  if (isAcked) {
1656
1722
  const ackedAtMs = Date.parse(readNonEmptyString(dispatch.updatedAt));
1657
1723
  const sinceAckMs = Number.isFinite(ackedAtMs) ? nowMs - ackedAtMs : Number.POSITIVE_INFINITY;
1658
1724
  const deathDeadlineMs = resolveAckedDeathDeadlineMs();
1659
- if (sinceAckMs < deathDeadlineMs) {
1660
- LOG.info('MeshReconcile', `Acked-hold: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read idle ${Number.isFinite(sinceAckMs) ? Math.round(sinceAckMs / 1000) + 's' : '∞'} since the generating_started ack — HOLDING synth indefinitely (worker is alive and will emit; a later real emit is idempotent). Death backstop fires at ${Math.round(deathDeadlineMs / 1000)}s or on consecutive read failures.`);
1725
+
1726
+ // ACKED-HOLD-IDLE-OVERTRUST fast-track. Maintain the continuous idle-with-final-assistant
1727
+ // streak. The streak starts (or continues) only while a final visible assistant message is
1728
+ // present; a tick with idle-but-no-assistant breaks it (the answer is not yet rendered).
1729
+ const holdState = inFlightAckedHoldState.get(synthKey);
1730
+ let fastTrackReady = false;
1731
+ if (evidence.finalSummary) {
1732
+ const idleSinceMs = holdState?.transcriptIdleSinceMs ?? nowMs;
1733
+ if (holdState && holdState.transcriptIdleSinceMs === undefined) {
1734
+ inFlightAckedHoldState.set(synthKey, { ...holdState, transcriptIdleSinceMs: idleSinceMs });
1735
+ }
1736
+ const fastTrackGraceMs = resolveAckedTranscriptFastTrackGraceMs();
1737
+ const idleHeldMs = nowMs - idleSinceMs;
1738
+ if (idleHeldMs >= fastTrackGraceMs) {
1739
+ fastTrackReady = true;
1740
+ LOG.info('MeshReconcile', `Acked-hold transcript fast-track: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read idle WITH a final assistant message for ${Math.round(idleHeldMs / 1000)}s continuous (grace ${Math.round(fastTrackGraceMs / 1000)}s) — promoting the synth ahead of the ${Math.round(deathDeadlineMs / 1000)}s death backstop; the worker's real emit was lost/late and a later one no-ops idempotently.`);
1741
+ }
1742
+ } else if (holdState?.transcriptIdleSinceMs !== undefined) {
1743
+ // Idle but no final assistant yet → not a turn-end; reset the streak.
1744
+ inFlightAckedHoldState.set(synthKey, { ...holdState, transcriptIdleSinceMs: undefined });
1745
+ }
1746
+
1747
+ // Hold indefinitely UNLESS the fast-track grace was met OR the absolute death deadline is
1748
+ // reached. The fast-track is the new fast path in front of the (preserved) 8-min backstop.
1749
+ if (!fastTrackReady && sinceAckMs < deathDeadlineMs) {
1750
+ LOG.info('MeshReconcile', `Acked-hold: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read idle ${Number.isFinite(sinceAckMs) ? Math.round(sinceAckMs / 1000) + 's' : '∞'} since the generating_started ack — HOLDING synth (worker presumed alive; a later real emit is idempotent). Transcript fast-track promotes at ${Math.round(resolveAckedTranscriptFastTrackGraceMs() / 1000)}s continuous idle-with-final-assistant; death backstop at ${Math.round(deathDeadlineMs / 1000)}s or on consecutive read failures.`);
1661
1751
  continue;
1662
1752
  }
1663
- LOG.warn('MeshReconcile', `Acked-hold death deadline reached: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) still idle ${Math.round(sinceAckMs / 1000)}s after the ack (deadline ${Math.round(deathDeadlineMs / 1000)}s) — synthesizing the missing completion as a notification-loss net (a real emit, if it ever lands, no-ops idempotently).`);
1753
+ if (!fastTrackReady) {
1754
+ LOG.warn('MeshReconcile', `Acked-hold death deadline reached: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) still idle ${Math.round(sinceAckMs / 1000)}s after the ack (deadline ${Math.round(deathDeadlineMs / 1000)}s) — synthesizing the missing completion as a notification-loss net (a real emit, if it ever lands, no-ops idempotently).`);
1755
+ }
1664
1756
  }
1665
1757
 
1666
1758
  // R4f (auxiliary, was R4e fix 3) — worker-emit priority. Secondary check: if the worker's
@@ -1677,8 +1769,6 @@ async function reconcileUnterminatedDirectDispatches(
1677
1769
  continue;
1678
1770
  }
1679
1771
 
1680
- const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
1681
- const evidence = extractFinalAssistantSummaryEvidence(messages);
1682
1772
  if (!evidence.finalSummary) continue; // no assistant result yet — nothing to attribute
1683
1773
 
1684
1774
  // STALE-SUMMARY guard (modal-parked / reused-session misattribution): a direct