@adhdev/daemon-core 0.9.82-rc.261 → 0.9.82-rc.263

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,5 +1,6 @@
1
- import type { GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
1
+ import type { DaemonBuildBehind, GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
2
2
  import { GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
3
+ import { getDaemonBuildInfo, type DaemonBuildInfo } from '../build-info.js';
3
4
 
4
5
  type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
5
6
 
@@ -14,6 +15,12 @@ export interface GitStatusOptions {
14
15
  * Callers should opt into this only for convergence-critical surfaces.
15
16
  */
16
17
  refreshUpstream?: boolean;
18
+ /**
19
+ * Test/override seam for the daemon build stamp used by the stale-build
20
+ * detector. Production callers omit this so the real baked-in build commit
21
+ * (getDaemonBuildInfo) is used.
22
+ */
23
+ daemonBuildInfo?: DaemonBuildInfo;
17
24
  }
18
25
 
19
26
  interface GitUpstreamProbe {
@@ -54,6 +61,8 @@ export async function getGitRepoStatus(
54
61
  || stashCount > 0
55
62
  || submoduleDirty;
56
63
 
64
+ const daemonBuildBehind = await detectDaemonBuildBehind(repo, submodules, options);
65
+
57
66
  return {
58
67
  workspace: repo.workspace,
59
68
  repoRoot: repo.repoRoot,
@@ -78,6 +87,7 @@ export async function getGitRepoStatus(
78
87
  stashCount,
79
88
  lastCheckedAt,
80
89
  submodules,
90
+ ...(daemonBuildBehind ? { daemonBuildBehind } : {}),
81
91
  };
82
92
  } catch (error) {
83
93
  if (error instanceof GitCommandError) {
@@ -91,6 +101,68 @@ export async function getGitRepoStatus(
91
101
  }
92
102
  }
93
103
 
104
+ /**
105
+ * Detect whether the running daemon's build commit is a STRICT ancestor of this
106
+ * workspace's HEAD (root) or any of its submodules' HEAD. This surfaces the
107
+ * "merged a fix to main but the live daemon still ships the old bundle" gap:
108
+ * once the fix is committed, the workspace HEAD advances past the daemon's
109
+ * baked-in build commit, but the daemon keeps the old behavior until it is
110
+ * rebuilt/redeployed and restarted.
111
+ *
112
+ * Conservative by construction — returns undefined unless ancestry is provable:
113
+ * - build commit unknown → undefined
114
+ * - build commit not an object in this repo/submodule (different repo) → skip
115
+ * - build commit === HEAD (daemon is current) → undefined
116
+ * - build commit NOT an ancestor of HEAD (daemon ahead / diverged) → undefined
117
+ * Any git error is swallowed (no warning) so a flaky probe never over-warns.
118
+ */
119
+ async function detectDaemonBuildBehind(
120
+ repo: ResolvedGitRepo,
121
+ submodules: GitSubmoduleStatus[] | undefined,
122
+ options: GitStatusOptions,
123
+ ): Promise<DaemonBuildBehind | undefined> {
124
+ const build = options.daemonBuildInfo ?? getDaemonBuildInfo();
125
+ if (!build.commit || build.commit === 'unknown') return undefined;
126
+
127
+ // Check the root repo first, then each submodule. The daemon build commit is
128
+ // baked from the daemon-core (oss submodule) HEAD, so on an adhdev
129
+ // superproject worktree the match is expected on the `oss` submodule, not the
130
+ // root — checking both keeps the helper repo-agnostic.
131
+ const scopes: Array<{ scope: string; repoPath: string }> = [
132
+ { scope: 'root', repoPath: repo.repoRoot || repo.workspace },
133
+ ];
134
+ for (const sub of submodules || []) {
135
+ if (sub.repoPath && !sub.error) scopes.push({ scope: sub.path, repoPath: sub.repoPath });
136
+ }
137
+
138
+ for (const { scope, repoPath } of scopes) {
139
+ try {
140
+ // Build commit must be a real object in THIS repo, else it's a different repo.
141
+ await runGit(repoPath, ['cat-file', '-e', `${build.commit}^{commit}`], options);
142
+ const headResult = await runGit(repoPath, ['rev-parse', 'HEAD'], options);
143
+ const head = headResult.stdout.trim();
144
+ if (!head || head === build.commit) continue;
145
+ // Strict ancestor: build commit is reachable from HEAD but is not HEAD.
146
+ await runGit(repoPath, ['merge-base', '--is-ancestor', build.commit, 'HEAD'], options);
147
+ // No throw → build commit IS an ancestor of HEAD → daemon is behind.
148
+ return {
149
+ buildCommit: build.commit,
150
+ buildCommitShort: build.commitShort,
151
+ head,
152
+ scope,
153
+ warning:
154
+ `Live daemon was built from ${build.commitShort} which is behind ${scope === 'root' ? 'workspace' : scope} HEAD ${head.slice(0, 7)}. ` +
155
+ `Merged code is NOT live until the daemon is rebuilt/redeployed and restarted — a local dist rebuild alone does not update a cloud daemon.`,
156
+ };
157
+ } catch {
158
+ // cat-file / merge-base non-zero exit (commit absent or not an ancestor)
159
+ // or any git error → not a provable staleness for this scope; try next.
160
+ continue;
161
+ }
162
+ }
163
+ return undefined;
164
+ }
165
+
94
166
  interface ParsedPorcelainStatus {
95
167
  branch: string | null;
96
168
  upstream: string | null;
@@ -68,10 +68,30 @@ export interface GitRepoStatus extends GitRepoIdentity {
68
68
  lastCheckedAt: number;
69
69
  /** Submodule statuses when auto-discover is enabled */
70
70
  submodules?: GitSubmoduleStatus[];
71
+ /**
72
+ * Set when the running daemon's build commit is a STRICT ancestor of this
73
+ * repo's HEAD (or a submodule HEAD) — i.e. the live daemon predates committed
74
+ * code in this workspace and is awaiting a deploy/restart to catch up.
75
+ * Omitted entirely when no staleness is provable (unknown build, commit not
76
+ * present in repo, or build commit == HEAD) to avoid over-warning.
77
+ */
78
+ daemonBuildBehind?: DaemonBuildBehind;
71
79
  error?: string;
72
80
  reason?: GitFailureReason;
73
81
  }
74
82
 
83
+ export interface DaemonBuildBehind {
84
+ /** Full build commit baked into the running daemon. */
85
+ buildCommit: string;
86
+ /** Short build commit. */
87
+ buildCommitShort: string;
88
+ /** HEAD commit the build commit is behind (repo or submodule). */
89
+ head: string;
90
+ /** Where the comparison matched: 'root' or the submodule path. */
91
+ scope: string;
92
+ warning: string;
93
+ }
94
+
75
95
  export type GitFileChangeStatus =
76
96
  | 'added'
77
97
  | 'modified'
package/src/index.ts CHANGED
@@ -217,9 +217,10 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
217
217
  export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
218
218
 
219
219
  // ── Mesh Work Queue (GUPP) ──
220
- export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
220
+ export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
221
221
  export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
222
- export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary } from './mesh/mesh-active-work.js';
222
+ export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
223
+ export type { StaleDirectPruneClassification } from './mesh/mesh-active-work.js';
223
224
  export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary } from './mesh/mesh-active-work.js';
224
225
  export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
225
226
  export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
@@ -306,6 +307,8 @@ export type {
306
307
  export { DaemonStatusReporter } from './status/reporter.js';
307
308
  export { buildSessionEntries, findCdpManager, hasCdpManager, isCdpConnected } from './status/builders.js';
308
309
  export { buildStatusSnapshot, buildMachineInfo } from './status/snapshot.js';
310
+ export { getDaemonBuildInfo } from './build-info.js';
311
+ export type { DaemonBuildInfo } from './build-info.js';
309
312
  export { normalizeManagedStatus, isManagedStatusWorking, isManagedStatusWaiting, normalizeActiveChatData } from './status/normalize.js';
310
313
  export type { ManagedStatus } from './status/normalize.js';
311
314
  export type { StatusSnapshotOptions, StatusSnapshot } from './status/snapshot.js';
@@ -408,6 +408,37 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
408
408
  return { activeWork: records, staleDirectWork, staleDirectWorkNote, terminalDirectWork, summary };
409
409
  }
410
410
 
411
+ /**
412
+ * staleReason strings (produced by sessionStatusFromNodes above) that indicate the original
413
+ * node/session is GONE from the live mesh — i.e. the staleDirect record is an orphaned ledger
414
+ * artifact, not active or recoverable work. These are the only reasons safe to prune from the
415
+ * active staleDirect surface. The "no provider acknowledgement" reason is deliberately excluded:
416
+ * those entries have a still-live node/session (staleDispatchUnacknowledged) and represent
417
+ * recoverable dispatch failures, never orphans.
418
+ */
419
+ export const PRUNABLE_ORPHAN_STALE_REASONS: ReadonlySet<string> = new Set([
420
+ 'direct task node is no longer in the live mesh',
421
+ 'direct task session is not present in live session records',
422
+ 'direct task has no node id',
423
+ ]);
424
+
425
+ export type StaleDirectPruneClassification = 'prunable_orphan' | 'prunable_terminal' | 'preserve_unacknowledged' | 'preserve_active';
426
+
427
+ /**
428
+ * Classify a direct-work record for the staleDirect prune path. Pure function — the prune tool
429
+ * uses this so the safety rules (never touch active work or recoverable unacknowledged dispatches)
430
+ * live next to the staleReason producers and are independently testable.
431
+ */
432
+ export function classifyStaleDirectForPrune(
433
+ record: Pick<MeshActiveWorkRecord, 'staleReason' | 'staleDispatchUnacknowledged' | 'terminal'>,
434
+ opts: { includeTerminal?: boolean } = {},
435
+ ): StaleDirectPruneClassification {
436
+ if (record.staleDispatchUnacknowledged === true) return 'preserve_unacknowledged';
437
+ if (record.terminal === true) return opts.includeTerminal ? 'prunable_terminal' : 'preserve_active';
438
+ if (record.staleReason && PRUNABLE_ORPHAN_STALE_REASONS.has(record.staleReason)) return 'prunable_orphan';
439
+ return 'preserve_active';
440
+ }
441
+
411
442
  export function buildCompactStaleDirectWorkSummary(
412
443
  staleDirectWork: MeshActiveWorkRecord[],
413
444
  opts: { sampleLimit?: number; detailHint?: string; note?: string } = {},