@adhdev/daemon-core 1.0.18-rc.2 → 1.0.18-rc.20

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.
Files changed (82) hide show
  1. package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
  2. package/dist/cli-adapters/provider-cli-shared.d.ts +33 -0
  3. package/dist/cli-adapters/pty-transport.d.ts +2 -1
  4. package/dist/commands/cli-manager.d.ts +9 -0
  5. package/dist/commands/process-lifecycle.d.ts +43 -0
  6. package/dist/commands/upgrade-helper.d.ts +1 -0
  7. package/dist/commands/windows-atomic-upgrade.d.ts +17 -1
  8. package/dist/config/mesh-json-config.d.ts +62 -0
  9. package/dist/index.d.ts +4 -2
  10. package/dist/index.js +6674 -4237
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +6361 -3927
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  15. package/dist/mesh/mesh-active-work.d.ts +1 -1
  16. package/dist/mesh/mesh-disk-retention.d.ts +105 -0
  17. package/dist/mesh/mesh-ledger.d.ts +28 -1
  18. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  19. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  20. package/dist/mesh/mesh-remote-event-pull.d.ts +3 -0
  21. package/dist/mesh/mesh-runtime-store.d.ts +11 -0
  22. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  23. package/dist/providers/auto-approve-modes.d.ts +14 -0
  24. package/dist/providers/chat-message-normalization.d.ts +22 -0
  25. package/dist/providers/cli-provider-instance-types.d.ts +4 -0
  26. package/dist/providers/cli-provider-instance.d.ts +78 -0
  27. package/dist/providers/contracts.d.ts +17 -0
  28. package/dist/providers/provider-schema.d.ts +1 -0
  29. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  30. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  31. package/dist/repo-mesh-types.d.ts +38 -6
  32. package/dist/shared-types.d.ts +4 -2
  33. package/package.json +3 -3
  34. package/src/boot/daemon-lifecycle.ts +10 -0
  35. package/src/cli-adapters/cli-state-engine.ts +220 -3
  36. package/src/cli-adapters/provider-cli-adapter.ts +41 -11
  37. package/src/cli-adapters/provider-cli-shared.ts +48 -0
  38. package/src/cli-adapters/pty-transport.d.ts +2 -1
  39. package/src/cli-adapters/pty-transport.ts +1 -1
  40. package/src/cli-adapters/session-host-transport.ts +8 -3
  41. package/src/commands/cli-manager.ts +72 -8
  42. package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
  43. package/src/commands/high-family/mesh-events.ts +13 -2
  44. package/src/commands/med-family/mesh-crud.ts +155 -0
  45. package/src/commands/med-family/mesh-queue.ts +74 -1
  46. package/src/commands/process-lifecycle.ts +248 -0
  47. package/src/commands/router-refine.ts +71 -4
  48. package/src/commands/router.ts +8 -0
  49. package/src/commands/upgrade-helper.ts +106 -82
  50. package/src/commands/windows-atomic-upgrade.ts +281 -35
  51. package/src/config/mesh-json-config.ts +111 -0
  52. package/src/index.ts +21 -1
  53. package/src/mesh/coordinator-prompt.ts +258 -11
  54. package/src/mesh/mesh-active-work.ts +11 -1
  55. package/src/mesh/mesh-completion-synthesis.ts +12 -1
  56. package/src/mesh/mesh-disk-retention.ts +370 -0
  57. package/src/mesh/mesh-event-classify.ts +19 -0
  58. package/src/mesh/mesh-event-forwarding.ts +64 -6
  59. package/src/mesh/mesh-events-utils.ts +42 -0
  60. package/src/mesh/mesh-ledger.ts +77 -0
  61. package/src/mesh/mesh-node-identity.ts +49 -0
  62. package/src/mesh/mesh-queue-assignment.ts +210 -11
  63. package/src/mesh/mesh-reconcile-loop.ts +306 -3
  64. package/src/mesh/mesh-refine-gates.ts +300 -0
  65. package/src/mesh/mesh-remote-event-pull.ts +68 -47
  66. package/src/mesh/mesh-runtime-store.ts +21 -0
  67. package/src/mesh/mesh-work-queue.ts +85 -2
  68. package/src/providers/auto-approve-modes.ts +103 -0
  69. package/src/providers/chat-message-normalization.ts +53 -0
  70. package/src/providers/cli-provider-instance-types.ts +53 -0
  71. package/src/providers/cli-provider-instance.ts +497 -15
  72. package/src/providers/contracts.ts +25 -1
  73. package/src/providers/provider-schema.ts +83 -0
  74. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  75. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  76. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +44 -0
  77. package/src/providers/types/interactive-prompt.ts +77 -0
  78. package/src/repo-mesh-types.ts +112 -12
  79. package/src/session-host/managed-host.ts +34 -0
  80. package/src/shared-types.ts +9 -1
  81. package/src/status/reporter.ts +1 -0
  82. package/src/status/snapshot.ts +2 -0
@@ -69,13 +69,16 @@ import {
69
69
  resolveCoordinatorDaemonIds,
70
70
  daemonHostsMesh,
71
71
  resolveCoordinatorSelfIds,
72
+ daemonIdListIncludes,
72
73
  } from './mesh-reconcile-identity.js';
73
74
  import {
74
75
  resolveAutoPruneMinAgeMs,
75
76
  resolvePendingHeldDrainEscalateMs,
76
77
  resolveReconcileIntervalMs,
77
78
  } from './mesh-reconcile-config.js';
78
- import { pullRemoteNodeQueues } from './mesh-remote-event-pull.js';
79
+ import { pullRemoteNodeQueues, pullPendingEventsFromNode, reprobeWorkerStatus } from './mesh-remote-event-pull.js';
80
+ import { isPurePtyTranscriptProvider } from '../cli-adapters/provider-cli-shared.js';
81
+ import { runDiskRetentionSweep, detectAndSignalOrphanWorktrees } from './mesh-disk-retention.js';
79
82
  import {
80
83
  reconcileUnterminatedDirectDispatches,
81
84
  autoPruneStaleDirectDispatches,
@@ -126,6 +129,16 @@ interface LiveCoordinator {
126
129
  // "restart does not clear it" symptom the operator needs visibility into).
127
130
  const coordinatorModalParkState = new Map<string, boolean>();
128
131
 
132
+ // Disk/worktree retention throttle. The reconcile tick runs every ~4s, but the
133
+ // retention sweep (fs walks + git worktree list) is far too heavy for that cadence
134
+ // and its artifacts age in days, so it runs at most once per hour. `undefined`
135
+ // means "never run yet" → runs on the first tick after daemon start so a long-lived
136
+ // backlog is reclaimed promptly rather than an hour later. Per-process; a restart
137
+ // re-runs it immediately, which is the desired behavior (a restart is exactly when
138
+ // stale artifacts from the previous run should be swept).
139
+ const DISK_RETENTION_INTERVAL_MS = 60 * 60 * 1000; // 1h
140
+ let lastDiskRetentionRunAt: number | undefined;
141
+
129
142
  // Find live CLI coordinator instances on THIS daemon, keyed by mesh.
130
143
  function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
131
144
  const out: LiveCoordinator[] = [];
@@ -721,10 +734,32 @@ const deliveredNoTurnUnknownStreak = new Map<string, number>();
721
734
  // immediately; UNKNOWN accrues a streak and re-drives only after RECLAIM_UNKNOWN_GRACE_TICKS.
722
735
  const deliveredUnconsumedUnknownStreak = new Map<string, number>();
723
736
 
737
+ // KIMI-PURE-PTY-COMPLETION-EMIT (Fix 2): how long a delivered assigned row whose worker is
738
+ // CONTINUOUSLY idle-WITH-a-final-assistant-message must stay so before we short-circuit it to
739
+ // 'completed' from transcript evidence — WITHOUT waiting the full DELIVERED_NO_TURN_DEADLINE_MS
740
+ // (15min). A pure-PTY worker (kimi and kin — no native transcript, no provider authority) whose
741
+ // generating_completed never emitted (the onTurnStarted idle→idle collapse Fix 1 addresses at the
742
+ // source) leaves the row 'assigned' with an idle worker that already rendered its answer. The
743
+ // F3 long-deadline reclaim's pollAssignedTaskTerminalEvidence would eventually recover it — but only
744
+ // after 15min, during which the coordinator ledger wrongly shows the task in flight. This EARLY
745
+ // reconcile applies the SAME idle-with-final-assistant-after-dispatch evidence bar (via
746
+ // pollAssignedTaskTerminalEvidence) after a few continuous-idle ticks, so a finished worker's task
747
+ // is marked complete promptly. Conservative by construction: the streak resets the instant a read is
748
+ // non-idle / lacks a final summary / is unreadable, and the poll itself enforces the after-dispatch
749
+ // stale-summary guard, so a mid-turn or warming-up worker is never falsely completed.
750
+ const ASSIGNED_IDLE_TRANSCRIPT_COMPLETE_MS = 8_000;
751
+
752
+ // Per-row continuous idle-with-final-assistant streak for the early transcript-evidence completion,
753
+ // keyed `${meshId}::${taskId}`, storing the wall-clock ms when the continuous run began. Pruned each
754
+ // pass to the currently-assigned rows (same as the UNKNOWN streaks). A non-idle / no-summary /
755
+ // unreadable tick clears the entry so the grace must re-accumulate from scratch.
756
+ const assignedIdleFinalAssistantSince = new Map<string, number>();
757
+
724
758
  // Test hook: clear the delivered-no-turn UNKNOWN streaks between cases.
725
759
  export function __resetReclaimUnknownStreakForTests(): void {
726
760
  deliveredNoTurnUnknownStreak.clear();
727
761
  deliveredUnconsumedUnknownStreak.clear();
762
+ assignedIdleFinalAssistantSince.clear();
728
763
  }
729
764
 
730
765
  // APPROVAL-INBOX-BLINDSPOT (Fix A.3): true when the assigned row's bound session is, per the
@@ -743,12 +778,129 @@ function assignedRowLiveStatusIsAwaitingApproval(
743
778
  ): boolean {
744
779
  if (!nodeId || !sessionId) return false;
745
780
  try {
746
- return sessionStatusFromNodes(mesh.nodes, nodeId, sessionId).status === 'awaiting_approval';
781
+ // Both blocked-on-input states hold the row: an approval (waiting_approval) and a
782
+ // question (awaiting_choice) legitimately park the worker awaiting the coordinator's
783
+ // mesh_approve / mesh_answer_question, so neither should accrue the reclaim streak
784
+ // (mission f1d25e11 extends the APPROVAL-INBOX-BLINDSPOT guard to questions).
785
+ const liveStatus = sessionStatusFromNodes(mesh.nodes, nodeId, sessionId).status;
786
+ return liveStatus === 'awaiting_approval' || liveStatus === 'awaiting_choice';
747
787
  } catch {
748
788
  return false;
749
789
  }
750
790
  }
751
791
 
792
+ // EARLY-IDLE-COMPLETION-FALSE-POSITIVE — resolve whether the assigned session's provider is
793
+ // the pure-PTY transcript class (kimi and kin — no native history, no provider authority), from
794
+ // the LOCAL instance when it is present. Returns:
795
+ // true → local instance is a pure-PTY provider (the class the early rescue exists for)
796
+ // false → local instance is NOT pure-PTY (native / provider-authoritative)
797
+ // undefined → no local instance (remote worker / gone / id-form skew) — pure-PTY unknowable here
798
+ // A remote pure-PTY worker is handled by the reprobe path (a fresh-idle read positively confirms
799
+ // the session is settled before the streak accrues), not by this local-only capability check.
800
+ function resolveLocalSessionPurePty(components: DaemonComponents, sessionId: string): boolean | undefined {
801
+ try {
802
+ const instances = components.instanceManager?.getByCategory?.('cli') || [];
803
+ const inst = instances.find((i: any) => {
804
+ const sid = readNonEmptyString(i?.getState?.().instanceId);
805
+ return sid && sessionIdsEquivalent(sid, sessionId);
806
+ }) as { provider?: unknown } | undefined;
807
+ if (!inst) return undefined; // remote / gone / id-form skew — not locally observable
808
+ const provider = inst.provider;
809
+ if (!provider || typeof provider !== 'object') return undefined;
810
+ return isPurePtyTranscriptProvider(provider as Parameters<typeof isPurePtyTranscriptProvider>[0]);
811
+ } catch {
812
+ return undefined;
813
+ }
814
+ }
815
+
816
+ // EARLY-IDLE-COMPLETION-FALSE-POSITIVE — decide whether the early transcript-evidence completion
817
+ // streak may ACCRUE this tick for an assigned row. This hardens the original arm gate, which only
818
+ // checked `resolveSessionBusyVerdict(...) !== 'GENERATING'` — a gate a REMOTE worker (local verdict
819
+ // UNKNOWN, not GENERATING) passed unconditionally, so its "8s continuous idle" streak degenerated to
820
+ // 8s of wall-clock even while the worker was genuinely mid-turn, and a startup-grace boot-window idle
821
+ // (before the first turn ever started) also passed. The row was then early-completed off a preamble.
822
+ //
823
+ // Two added requirements, defense-in-depth on top of the existing confirmed-delivery / no-terminal-
824
+ // ledger / bound-session gate the caller applies:
825
+ // (a) POSITIVE idle evidence, not merely "not GENERATING". A LOCAL IDLE_CONFIRMED verdict qualifies
826
+ // directly. A remote/UNKNOWN verdict is RE-PROBED with a fresh read_chat status this tick: only
827
+ // a definitively 'idle' read lets the streak accrue; any active status (generating/waiting/…),
828
+ // or an inconclusive read, resets it. This is what stops the remote worker's mid-turn busy from
829
+ // silently passing as "continuous idle".
830
+ // (b) TURN-START evidence, so a boot-window / never-consumed idle cannot early-complete a preamble.
831
+ // taskDeliveryConsumed===true (the worker emitted agent:generating_started) satisfies it. When
832
+ // the delivery was never consumed, only a LOCAL pure-PTY provider — the exact class that never
833
+ // emits generating_started and that this rescue exists for — is still allowed; a native/
834
+ // provider-authoritative worker that hasn't started its turn is NOT armed (it will complete via
835
+ // its own emit or the normal grace). A remote-not-consumed worker whose provider class is
836
+ // unknowable locally falls back to the reprobe: it may accrue only once (a) reads it positively
837
+ // idle, and the poll's post-dispatch + trailing-tool-activity guards remain the final net.
838
+ async function evaluateEarlyIdleTranscriptArm(
839
+ components: DaemonComponents,
840
+ mesh: { id: string; nodes?: Array<{ id: string; daemonId?: string; workspace?: string }> },
841
+ store: MeshRuntimeStore,
842
+ row: { id: string; assignedSessionId?: string; assignedNodeId?: string; assignedProviderType?: string },
843
+ localDaemonId?: string,
844
+ selfIds: string[] = [],
845
+ ): Promise<boolean> {
846
+ const sessionId = readNonEmptyString(row.assignedSessionId);
847
+ if (!sessionId) return false;
848
+
849
+ // (a) POSITIVE idle evidence.
850
+ const verdict = resolveSessionBusyVerdict(components, sessionId);
851
+ if (verdict === 'GENERATING') return false; // locally observed mid-turn — never accrue
852
+ if (verdict === 'UNKNOWN') {
853
+ // Remote / gone / id-form skew. First consult the LIVE mesh node snapshot (the same
854
+ // cross-daemon status feed the approval-hold guard and mesh_status use): if it already
855
+ // reports a definitively NON-idle status (generating / waiting_approval / awaiting_choice /
856
+ // …), the worker is not settled — reset the streak WITHOUT a read_chat. This keeps the
857
+ // streak the cheap per-tick gate for the common case and avoids probing a worker the graph
858
+ // already knows is busy or parked at an approval.
859
+ const nodeId = readNonEmptyString(row.assignedNodeId);
860
+ const node = (mesh.nodes ?? []).find(n => n.id === nodeId);
861
+ const liveStatus = nodeId
862
+ ? readNonEmptyString(sessionStatusFromNodes(mesh.nodes, nodeId, sessionId).status).toLowerCase()
863
+ : '';
864
+ if (liveStatus && liveStatus !== 'idle') return false; // live graph says non-idle → not a turn-end
865
+ if (!liveStatus) {
866
+ // No live snapshot for this session — re-probe the worker's own status this tick so a
867
+ // genuinely busy remote worker breaks the streak instead of coasting through as "not
868
+ // GENERATING". getInstance may be absent on some component surfaces (tests / standalone),
869
+ // so guard it.
870
+ const nodeDaemonId = readNonEmptyString(node?.daemonId);
871
+ const isLocalNode = !nodeDaemonId
872
+ || daemonIdsEquivalent(nodeDaemonId, localDaemonId)
873
+ || daemonIdListIncludes(selfIds, nodeDaemonId)
874
+ || !!components.instanceManager?.getInstance?.(sessionId);
875
+ const providerType = readNonEmptyString(row.assignedProviderType);
876
+ const readArgs: Record<string, unknown> = {
877
+ sessionId,
878
+ targetSessionId: sessionId,
879
+ tailLimit: 1,
880
+ ...(node?.workspace ? { workspace: node.workspace } : {}),
881
+ ...(providerType ? { agentType: providerType, providerType } : {}),
882
+ };
883
+ const probedStatus = await reprobeWorkerStatus(components, { isLocalNode, nodeDaemonId, readArgs });
884
+ // Only a positively-idle re-probe lets the streak accrue. A non-idle status (the worker
885
+ // IS busy) or an inconclusive read (null — transport error / no payload) resets it: we
886
+ // never treat "couldn't tell" as idle for this early-completion path.
887
+ if (probedStatus !== 'idle') return false;
888
+ }
889
+ }
890
+
891
+ // (b) TURN-START evidence (guards the boot-window / preamble false positive).
892
+ if (store.taskDeliveryConsumed(mesh.id, row.id)) return true; // worker emitted generating_started
893
+ // Delivery never consumed. Preserve the kimi rescue ONLY for the LOCAL pure-PTY class (which
894
+ // never emits generating_started); a native/provider-authoritative local worker that hasn't
895
+ // started its turn must NOT be armed off a boot-window idle. For a remote worker (pure-PTY
896
+ // unknowable here) the positive-idle reprobe in (a) is the gate — allow accrual and let the poll
897
+ // enforce post-dispatch + trailing-tool-activity finality.
898
+ const localPurePty = resolveLocalSessionPurePty(components, sessionId);
899
+ if (localPurePty === true) return true; // local pure-PTY — the class this rescue targets
900
+ if (localPurePty === false) return false; // local native/provider-owned, turn not started — hold
901
+ return verdict === 'UNKNOWN'; // remote, unknowable class — trust the (a) reprobe
902
+ }
903
+
752
904
  // PHASE 2.5 — assigned-stranded dispatch watchdog (Bug B). claimNextTask atomically
753
905
  // flips a row to 'assigned' BEFORE the fire-and-forget dispatch runs. If that dispatch
754
906
  // neither rejects (→ no .catch requeue) nor is confirmed delivered — a relay that hangs
@@ -765,6 +917,8 @@ async function recoverStrandedAssignedDispatches(
765
917
  components: DaemonComponents,
766
918
  mesh: { id: string; nodes?: Array<{ id: string; daemonId?: string; workspace?: string }> },
767
919
  store: MeshRuntimeStore,
920
+ localDaemonId?: string,
921
+ selfIds: string[] = [],
768
922
  ): Promise<void> {
769
923
  const meshId = mesh.id;
770
924
  const assigned = getQueue(meshId, { status: ['assigned'] });
@@ -781,10 +935,82 @@ async function recoverStrandedAssignedDispatches(
781
935
  for (const key of [...deliveredUnconsumedUnknownStreak.keys()]) {
782
936
  if (key.startsWith(meshKeyPrefix) && !assignedKeys.has(key)) deliveredUnconsumedUnknownStreak.delete(key);
783
937
  }
938
+ for (const key of [...assignedIdleFinalAssistantSince.keys()]) {
939
+ if (key.startsWith(meshKeyPrefix) && !assignedKeys.has(key)) assignedIdleFinalAssistantSince.delete(key);
940
+ }
784
941
  for (const row of assigned) {
785
942
  const dispatchedAtMs = Date.parse(row.dispatchTimestamp ?? '');
786
943
  if (!Number.isFinite(dispatchedAtMs)) continue; // no dispatch ts → can't age it
787
944
  const ageMs = nowMs - dispatchedAtMs;
945
+ const idleTranscriptStreakKey = `${meshId}::${row.id}`;
946
+ // KIMI-PURE-PTY-COMPLETION-EMIT (Fix 2): EARLY transcript-evidence completion for a
947
+ // delivered assigned row whose worker is already idle with a final assistant answer, so a
948
+ // pure-PTY worker whose generating_completed never emitted is marked done PROMPTLY instead of
949
+ // sitting 'assigned' until the 15-min DELIVERED_NO_TURN_DEADLINE_MS reclaim. Gate on a
950
+ // CONFIRMED delivery (a never-delivered row is handled by dispatch/redrive, not completion)
951
+ // and a bound session, then require the worker to read idle-WITH-final-assistant continuously
952
+ // for ASSIGNED_IDLE_TRANSCRIPT_COMPLETE_MS before polling the decisive transcript evidence.
953
+ // The streak is the cheap gate (avoids a read every tick); the read only runs once the grace
954
+ // elapses. pollAssignedTaskTerminalEvidence enforces idle + final-assistant-after-dispatch, so
955
+ // a mid-turn / stale-tail / unreadable worker is never falsely completed — a non-qualifying
956
+ // tick clears the streak below.
957
+ // EARLY-IDLE-COMPLETION-FALSE-POSITIVE: the arm gate now requires POSITIVE idle evidence
958
+ // (a fresh-idle reprobe for a remote/UNKNOWN worker, not merely "not GENERATING") AND
959
+ // turn-start evidence (delivery consumed, or the local pure-PTY class this rescue targets),
960
+ // so a mid-turn remote worker or a boot-window preamble can no longer coast the continuous-
961
+ // idle streak to a false completion. See evaluateEarlyIdleTranscriptArm.
962
+ const earlyArm =
963
+ store.taskHasConfirmedDelivery(meshId, row.id)
964
+ && !findTerminalLedgerEvidenceForTask({ meshId, taskId: row.id })
965
+ && readNonEmptyString(row.assignedSessionId)
966
+ ? await evaluateEarlyIdleTranscriptArm(components, mesh, store, row, localDaemonId, selfIds)
967
+ : false;
968
+ if (earlyArm) {
969
+ const since = assignedIdleFinalAssistantSince.get(idleTranscriptStreakKey);
970
+ if (since === undefined) {
971
+ assignedIdleFinalAssistantSince.set(idleTranscriptStreakKey, nowMs);
972
+ } else if (nowMs - since >= ASSIGNED_IDLE_TRANSCRIPT_COMPLETE_MS) {
973
+ const terminalEvidence = await pollAssignedTaskTerminalEvidence(components, mesh, row);
974
+ if (terminalEvidence) {
975
+ assignedIdleFinalAssistantSince.delete(idleTranscriptStreakKey);
976
+ updateTaskStatus(meshId, row.id, terminalEvidence);
977
+ if (!findTerminalLedgerEvidenceForTask({ meshId, taskId: row.id })) {
978
+ try {
979
+ appendLedgerEntry(meshId, {
980
+ kind: terminalEvidence === 'completed' ? 'task_completed' : 'task_failed',
981
+ nodeId: row.assignedNodeId,
982
+ sessionId: row.assignedSessionId,
983
+ providerType: row.assignedProviderType,
984
+ payload: {
985
+ taskId: row.id,
986
+ event: 'agent:generating_completed',
987
+ source: 'early_idle_transcript_evidence',
988
+ },
989
+ });
990
+ } catch { /* best-effort ledger write */ }
991
+ }
992
+ LOG.warn('MeshReconcile', `Early-completed assigned task ${row.id} on mesh ${meshId} `
993
+ + `(node=${row.assignedNodeId ?? '?'} session=${row.assignedSessionId ?? '?'}): worker idle with a `
994
+ + `final assistant message after dispatch for ≥${Math.round(ASSIGNED_IDLE_TRANSCRIPT_COMPLETE_MS / 1000)}s — `
995
+ + `the completion event was lost/late (pure-PTY provider), task is ${terminalEvidence} without waiting the 15-min deadline`);
996
+ traceMeshEventDrop('assigned_early_transcript_completed', {
997
+ taskId: row.id,
998
+ sessionId: row.assignedSessionId,
999
+ nodeId: row.assignedNodeId,
1000
+ meshId,
1001
+ event: 'agent:generating_completed',
1002
+ }, terminalEvidence);
1003
+ continue;
1004
+ }
1005
+ // Poll was inconclusive (mid-turn re-check / stale tail / unreadable) — reset the
1006
+ // streak so it must re-accumulate a fresh continuous-idle run before the next poll.
1007
+ assignedIdleFinalAssistantSince.delete(idleTranscriptStreakKey);
1008
+ }
1009
+ } else {
1010
+ // Not a qualifying tick (no confirmed delivery, generating, terminal ledger present, or
1011
+ // no bound session) — break the continuous-idle run.
1012
+ assignedIdleFinalAssistantSince.delete(idleTranscriptStreakKey);
1013
+ }
788
1014
  // DELIVERED-NOT-CONSUMED short-grace re-drive (remote autoLaunch delivered≠consumed gap).
789
1015
  // Runs BEFORE the ASSIGNED_STRANDED_DEADLINE_MS confirm-window gate below because its whole
790
1016
  // point is to recover a delivered-but-unconsumed row well inside that window. A remote
@@ -816,6 +1042,45 @@ async function recoverStrandedAssignedDispatches(
816
1042
  updateTaskStatus(meshId, row.id, status);
817
1043
  continue;
818
1044
  }
1045
+ // GENERATING-STARTED-CONSUME-RACE (fix B): the delivery-consume that clears this
1046
+ // gate (delivered → acked) runs ONLY when the coordinator pulls the worker's queued
1047
+ // agent:generating_started in PHASE 1 (handleMeshForwardEvent → consumeSessionDelivery).
1048
+ // A worker may have emitted generating_started ALREADY, but if PHASE 1 has not yet
1049
+ // pulled that specific event (a skipped/slow peer tick, or the event was queued after
1050
+ // this tick's pull ran), the delivery row still reads 'delivered' here and the redrive
1051
+ // below tears a genuinely-generating remote worker off its task and re-injects the
1052
+ // prompt (the delivered_not_consumed_redrive symptom). Close the race by issuing a
1053
+ // TARGETED, in-process last-chance pull of THIS node's queue right now, then
1054
+ // re-reading taskDeliveryConsumed(): if the worker's generating_started was waiting to
1055
+ // be pulled, the pull consumes the delivery this tick and we skip the redrive entirely.
1056
+ // Best-effort and self-scoped (the same skip/peer guards as PHASE 1) — a miss just
1057
+ // falls through to the existing UNKNOWN-streak grace, so this only ever removes false
1058
+ // redrives, never adds one. A local (co-hosted) worker has nothing to pull (the node's
1059
+ // daemon is a self id) and this is a fast no-op.
1060
+ const assignedNode = row.assignedNodeId
1061
+ ? mesh.nodes?.find(n => meshNodeIdMatches(n, row.assignedNodeId))
1062
+ : undefined;
1063
+ if (assignedNode?.daemonId) {
1064
+ try {
1065
+ await pullPendingEventsFromNode(
1066
+ components,
1067
+ meshId,
1068
+ assignedNode,
1069
+ localDaemonId,
1070
+ selfIds,
1071
+ selfIds.length > 0
1072
+ ? selfIds.map(id => ({ meshId, coordinatorDaemonId: id }))
1073
+ : [{ meshId }],
1074
+ );
1075
+ } catch { /* best-effort last-chance pull */ }
1076
+ if (store.taskDeliveryConsumed(meshId, row.id)) {
1077
+ // The pull delivered the worker's generating_started (or a terminal event) and
1078
+ // consumed the delivery — the task is genuinely live/handled. Reset any accrued
1079
+ // streak and leave the row to PHASE 4's completion reconcile.
1080
+ deliveredUnconsumedUnknownStreak.delete(`${meshId}::${row.id}`);
1081
+ continue;
1082
+ }
1083
+ }
819
1084
  const shortStreakKey = `${meshId}::${row.id}`;
820
1085
  const verdict = row.assignedSessionId
821
1086
  ? resolveSessionBusyVerdict(components, row.assignedSessionId)
@@ -1211,7 +1476,7 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
1211
1476
  const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
1212
1477
  if (!daemonHostsMesh(mesh, selfIds)) continue;
1213
1478
  try {
1214
- await recoverStrandedAssignedDispatches(components, mesh, store);
1479
+ await recoverStrandedAssignedDispatches(components, mesh, store, localDaemonId, selfIds);
1215
1480
  } catch (e: any) {
1216
1481
  LOG.warn('MeshReconcile', `Assigned-stranded watchdog failed for mesh ${mesh.id}: ${e?.message || e}`);
1217
1482
  }
@@ -1354,6 +1619,44 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
1354
1619
  }
1355
1620
  }
1356
1621
 
1622
+ // ── PHASE 6: disk / worktree retention (hourly, mission 86def38d) ──────────
1623
+ // Legacy on-disk artifacts under ~/.adhdev accumulated with NO lifetime and grew
1624
+ // the data volume until a refine bootstrap failed at 98% disk. This throttled pass
1625
+ // (≤1×/hour — the artifacts age in days and the fs/git walk is too heavy for the 4s
1626
+ // tick) reclaims them:
1627
+ // • JSONL ledger files older than 30d (legacy after the SQLite ledger)
1628
+ // • terminated session-host runtime files older than 14d (live never touched)
1629
+ // • mesh-runtime.db.bak-* backups older than 7d
1630
+ // • orphan worktree DETECTION → cleanup_candidate ledger signal (NEVER deleted).
1631
+ // Runs BEFORE PHASE 2's early return so it fires whether or not a live CLI
1632
+ // coordinator exists on this daemon. Isolated in try/catch so it can never kill the
1633
+ // tick. All file deletions are defensive (age + live-runtime guards in the pure
1634
+ // selectors); worktree cleanup stays manual/coordinator-driven.
1635
+ {
1636
+ const nowMs = Date.now();
1637
+ const due = lastDiskRetentionRunAt === undefined
1638
+ || (nowMs - lastDiskRetentionRunAt) >= DISK_RETENTION_INTERVAL_MS;
1639
+ if (due) {
1640
+ lastDiskRetentionRunAt = nowMs;
1641
+ try {
1642
+ runDiskRetentionSweep(nowMs);
1643
+ } catch (e: any) {
1644
+ LOG.warn('MeshReconcile', `Disk retention sweep failed: ${e?.message || e}`);
1645
+ }
1646
+ // Orphan-worktree detection is per-mesh (needs the mesh's base repo + node set)
1647
+ // and only for meshes this daemon hosts (its local base checkout is the git anchor).
1648
+ for (const mesh of listMeshes()) {
1649
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
1650
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
1651
+ try {
1652
+ await detectAndSignalOrphanWorktrees(mesh, nowMs);
1653
+ } catch (e: any) {
1654
+ LOG.warn('MeshReconcile', `Orphan worktree detection failed for mesh ${mesh.id}: ${e?.message || e}`);
1655
+ }
1656
+ }
1657
+ }
1658
+ }
1659
+
1357
1660
  // ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
1358
1661
  const coordinators = findLiveCoordinators(components);
1359
1662
  if (coordinators.length === 0) {