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

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.
@@ -6,7 +6,7 @@ export declare function tryAssignQueueTask(components: DaemonComponents, meshId:
6
6
  /** Active assignments that hold the one-active-per-node / global-parallel invariant
7
7
  * (everything except read-only diagnoses, which run unbounded by the write cap). */
8
8
  export declare function activeWriteAssignedCount(meshId: string): number;
9
- /** Active read-only (live_debug_readonly) assignments, for the read-only safety cap. */
9
+ /** Active read-only assignments, for the read-only safety cap. */
10
10
  export declare function activeReadonlyAssignedCount(meshId: string): number;
11
11
  /**
12
12
  * Order eligible nodes for assignment per the mesh scheduling pipeline:
@@ -7,6 +7,28 @@ export type MeshTaskMode = 'code_change' | 'validation' | 'live_debug_readonly'
7
7
  export declare const ACTIVE_MESH_QUEUE_STATUSES: MeshActiveTaskStatus[];
8
8
  export declare const HISTORICAL_MESH_QUEUE_STATUSES: MeshHistoricalTaskStatus[];
9
9
  export declare const MESH_TASK_MODES: MeshTaskMode[];
10
+ /**
11
+ * QUEUE-NODE-SERIALIZATION: single source of truth for "is this task read-only?".
12
+ *
13
+ * Read-only classification used to be inlined as `task.taskMode === 'live_debug_readonly'`
14
+ * at every enforcement site (node-conflict claim gate, auto-launch isolation, the
15
+ * write/readonly cap counters, the write guardrail). That spread-out comparison is the
16
+ * exact recurring-defect class — one site drifting from the others silently makes the same
17
+ * task read-only at some gates and write at others, i.e. partial serialization. All sites
18
+ * MUST call this predicate so the classification is decided in exactly one place.
19
+ *
20
+ * Two orthogonal inputs feed the same boolean axis (kept backward-compatible):
21
+ * • `readonly === true` — the explicit boolean axis (new API surface).
22
+ * • `taskMode === 'live_debug_readonly'` — the original enum value, preserved as an
23
+ * OR-fallback so existing live_debug_readonly tasks keep behaving identically.
24
+ *
25
+ * Accepts any task-like shape (full {@link MeshWorkQueueEntry} or a bare
26
+ * `{ readonly?, taskMode? }`) so the daemon-core and mcp-server boundaries can share it.
27
+ */
28
+ export declare function isTaskReadonly(task: {
29
+ readonly?: boolean;
30
+ taskMode?: MeshTaskMode | string;
31
+ } | null | undefined): boolean;
10
32
  export interface MeshTaskModeValidationResult {
11
33
  valid: boolean;
12
34
  taskMode?: MeshTaskMode;
@@ -14,13 +36,21 @@ export interface MeshTaskModeValidationResult {
14
36
  allowedOperations?: string[];
15
37
  }
16
38
  export declare function normalizeMeshTaskMode(value: unknown): MeshTaskMode | undefined;
17
- export declare function validateMeshTaskModeRequest(mode: unknown, message: string): MeshTaskModeValidationResult;
39
+ export declare function validateMeshTaskModeRequest(mode: unknown, message: string, readonly?: boolean): MeshTaskModeValidationResult;
18
40
  export interface MeshWorkQueueEntry {
19
41
  id: string;
20
42
  meshId: string;
21
43
  message: string;
22
44
  status: MeshTaskStatus;
23
45
  taskMode?: MeshTaskMode;
46
+ /**
47
+ * QUEUE-NODE-SERIALIZATION: explicit read-only axis, orthogonal to taskMode. When
48
+ * true the task is treated as read-only by every scheduling gate (no node-busy
49
+ * isolation, counted under the read-only cap, write commands rejected) regardless of
50
+ * its taskMode. Decided exclusively through {@link isTaskReadonly}; `taskMode ===
51
+ * 'live_debug_readonly'` remains an OR-fallback so legacy rows behave unchanged.
52
+ */
53
+ readonly?: boolean;
24
54
  /** If specified, only this node can claim the task (used by legacy mesh_send_task) */
25
55
  targetNodeId?: string;
26
56
  /** If specified, only this runtime session can claim the task */
@@ -140,6 +170,8 @@ export declare function enqueueTask(meshId: string, message: string, opts?: {
140
170
  targetNodeId?: string;
141
171
  targetSessionId?: string;
142
172
  taskMode?: MeshTaskMode | string;
173
+ /** QUEUE-NODE-SERIALIZATION: explicit read-only axis (orthogonal to taskMode). */
174
+ readonly?: boolean;
143
175
  requiredTags?: string[];
144
176
  /** M1: tasks that must complete before this one is claimable. */
145
177
  dependsOn?: string[];
@@ -174,6 +206,8 @@ export declare function recordDirectDispatchTask(meshId: string, message: string
174
206
  assignedNodeId?: string;
175
207
  assignedSessionId?: string;
176
208
  taskMode?: MeshTaskMode | string;
209
+ /** QUEUE-NODE-SERIALIZATION: explicit read-only axis (orthogonal to taskMode). */
210
+ readonly?: boolean;
177
211
  dispatchedAt?: string;
178
212
  }): MeshWorkQueueEntry | null;
179
213
  /**
@@ -1,6 +1,24 @@
1
1
  import type { ChatMessage } from '../types.js';
2
2
  export declare const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 16000;
3
3
  export declare function extractFinalSummaryFromMessages(messages: ChatMessage[] | null | undefined, maxChars?: number): string;
4
+ /**
5
+ * Turn-scoped variant of extractFinalSummaryFromMessages. Selects the last
6
+ * user-facing assistant/model bubble whose own timestamp is at/after the
7
+ * producing turn's start (`minTimestampMs`). This is the NOTIF Defect-B fix:
8
+ * a completion event's finalSummary must describe the turn THAT completed, not
9
+ * the prior task's last bubble. For native-source providers (claude-cli) the
10
+ * external transcript holds the ENTIRE session history filtered only by session
11
+ * start, so a completion debounce that fires before the producing turn's final
12
+ * assistant bubble has landed would otherwise echo the previous task's tail.
13
+ * A message whose timestamp predates the turn start is skipped; if no in-turn
14
+ * assistant bubble exists yet, returns '' (weak/empty) — never the stale tail.
15
+ *
16
+ * Mirrors the reconcile path's transcriptAfterDispatch guard (mesh-events-stale):
17
+ * a bubble only counts if its timestamp proves it was produced after the turn began.
18
+ * When `minTimestampMs` is undefined the behaviour is identical to the unscoped
19
+ * extractor (no turn boundary known → no filtering).
20
+ */
21
+ export declare function extractFinalSummaryFromMessagesAfter(messages: ChatMessage[] | null | undefined, minTimestampMs: number | undefined, maxChars?: number): string;
4
22
  /**
5
23
  * Like extractFinalSummaryFromMessages but also returns the ISO timestamp of the
6
24
  * selected final assistant/model message. Completion reconciliation needs the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.407",
3
+ "version": "0.9.82-rc.409",
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.407",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.409",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -29,6 +29,15 @@ function isModuleNotFoundError(error: unknown, ref: string): boolean {
29
29
  return code === 'MODULE_NOT_FOUND' && message.includes(ref);
30
30
  }
31
31
 
32
+ // Identifies the host runtime so a binding-load failure names the exact ABI it
33
+ // looked for. The underlying binding is N-API (ABI-stable), so a failure here is
34
+ // almost always "no prebuilt directory addressed this triplet" rather than a
35
+ // true ABI incompatibility — making the triplet the single most useful
36
+ // diagnostic to surface in an env-blocker report.
37
+ function runtimeTriplet(): string {
38
+ return `${process.platform}-${process.arch}-node${process.versions.modules}`;
39
+ }
40
+
32
41
  function normalizeBinding(mod: any, ref: string): GhosttyVtBinding {
33
42
  const binding = mod?.default?.createTerminal
34
43
  ? mod.default
@@ -75,7 +84,8 @@ function loadGhosttyVtBinding(): GhosttyVtBinding {
75
84
 
76
85
  cachedBinding = null;
77
86
  cachedBindingError = new Error(
78
- `ghostty-vt binding unavailable (${errors.join('; ') || 'no candidates tried'})`,
87
+ `ghostty-vt binding unavailable for runtime ${runtimeTriplet()} ` +
88
+ `(${errors.join('; ') || 'no candidates tried'})`,
79
89
  );
80
90
  throw cachedBindingError;
81
91
  }
@@ -752,6 +752,17 @@ export async function handleResolveAction(h: CommandHelpers, args: any): Promise
752
752
  : status?.status;
753
753
  LOG.info('Command', `[resolveAction] CLI PTY gate target=${String(args?.targetSessionId || '')} rawStatus=${String(status?.status || '')} effectiveStatus=${String(effectiveStatus || '')} statusModal=${statusModal ? 'yes' : 'no'} surfacedModal=${surfacedModal ? 'yes' : 'no'} parsedModal=${parsedModal ? 'yes' : 'no'} instance=${targetInstance ? 'yes' : 'no'}`);
754
754
  if (!effectiveModal) {
755
+ // APPROVAL Defect-B (live re-probe race): the modal is gone because the worker
756
+ // already resolved this very approval moments ago (delegated auto-approve fired,
757
+ // or a prior resolveAction landed) and the coordinator's approve raced in just
758
+ // after. That is NOT a caller error — return a SOFT already_resolved result so the
759
+ // coordinator does not hard-fail the task on a benign race. Mirrors the in-modal
760
+ // idempotency guard below (isApprovalRecentlyResolved → stalePrompt). Only a session
761
+ // that never had a recently-resolved approval reports the hard 'Not in approval state'.
762
+ if (typeof adapter.isApprovalRecentlyResolved === 'function' && adapter.isApprovalRecentlyResolved()) {
763
+ LOG.info('Command', `[resolveAction] CLI PTY → already_resolved (modal gone, resolved within cooldown)`);
764
+ return { success: true, alreadyResolved: true, status: 'already_resolved' };
765
+ }
755
766
  return { success: false, error: 'Not in approval state' };
756
767
  }
757
768
  const buttons: string[] = Array.isArray(effectiveModal.buttons) ? effectiveModal.buttons : [];
@@ -462,7 +462,10 @@ async function gitCheckpoint(
462
462
  const repo = await resolveGitRepository(workspace);
463
463
  const repoRoot = repo.repoRoot!;
464
464
 
465
- const statusResult = await getGitRepoStatus(workspace);
465
+ // Decision-then-mutate path: this conflict / dirty-submodule precheck gates a real
466
+ // `git add` + `git commit`. Bypass the C1 TTL cache so the checkpoint never proceeds
467
+ // on a stale "clean" verdict.
468
+ const statusResult = await getGitRepoStatus(workspace, { forceFresh: true });
466
469
  if (statusResult.hasConflicts) {
467
470
  throw new GitCommandError('conflict', 'Repository has conflicts — resolve before checkpointing');
468
471
  }
@@ -12,12 +12,45 @@ import {
12
12
  type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
13
13
 
14
14
  /**
15
- * Last successfully-collected status per workspace, used to survive a transient git
16
- * failure (timeout, slow Windows spawn under load, a momentary lock) WITHOUT dropping
17
- * the node out of the mesh graph. A genuine "not a git repository" answer is NOT a
18
- * transient failure it never populates this cache and always reports isGitRepo:false.
15
+ * TTL for the happy-path result cache (C1). reconcile fires every 4s and mesh_status
16
+ * is polled; a single getGitRepoStatus on a 1-submodule repo spawns ~13-15 git
17
+ * processes (+ optional network fetch) which on Windows is ~10-15s. Within this short
18
+ * window, repeat callers that share the same option shape are served the already
19
+ * collected status instead of re-shelling. Kept well under the 4s reconcile cadence so
20
+ * a genuinely fresh probe still happens at least roughly once per reconcile tick.
19
21
  */
20
- const lastKnownGoodStatus = new Map<string, GitRepoStatus>();
22
+ export const GIT_STATUS_CACHE_TTL_MS = 1500;
23
+
24
+ /**
25
+ * Last successfully-collected status per (workspace, option-shape), used for two things:
26
+ * 1. C1 TTL result cache — on the happy path, a cached entry younger than
27
+ * GIT_STATUS_CACHE_TTL_MS is returned directly (gated by `cachedAt`).
28
+ * 2. Transient-failure fallback — survive a transient git failure (timeout, slow
29
+ * Windows spawn under load, a momentary lock) WITHOUT dropping the node out of the
30
+ * mesh graph by re-serving the last good status, re-stamped as stale.
31
+ * A genuine "not a git repository" answer is NOT a transient failure — it never
32
+ * populates this cache and always reports isGitRepo:false. Only a successful, fully
33
+ * populated status is ever cached; error/empty results never are.
34
+ *
35
+ * The key folds in the option fields that change the SHAPE of the collected result
36
+ * (includeSubmodules, refreshUpstream) so different callers don't read each other's
37
+ * partial results. Fields that only affect timing/policy (timeoutMs, daemonBuildInfo,
38
+ * changeImpactConfig, submoduleIgnorePaths) are NOT part of the key — they don't change
39
+ * what a fresh same-shape collection would currently return for the happy path, and the
40
+ * sub-caches (changeImpactEvalCache) already key on their own invalidators.
41
+ */
42
+ interface CachedStatusEntry {
43
+ status: GitRepoStatus;
44
+ cachedAt: number;
45
+ }
46
+ const lastKnownGoodStatus = new Map<string, CachedStatusEntry>();
47
+
48
+ /** Cache key folding in only the option fields that change the result shape. */
49
+ function statusCacheKey(workspace: string, options: GitStatusOptions): string {
50
+ const includeSubmodules = options.includeSubmodules !== false;
51
+ const refreshUpstream = options.refreshUpstream === true;
52
+ return `${workspace}\0sub=${includeSubmodules ? 1 : 0}\0up=${refreshUpstream ? 1 : 0}`;
53
+ }
21
54
 
22
55
  /**
23
56
  * Memoized Change Impact evaluation, keyed by the inputs that can change the
@@ -45,13 +78,31 @@ interface ChangeImpactConfigCacheEntry {
45
78
  }
46
79
  const changeImpactConfigCache = new Map<string, ChangeImpactConfigCacheEntry>();
47
80
 
81
+ /**
82
+ * C3: memoized ancestry verdict for the daemon-build-behind probe, keyed by
83
+ * `${repoPath}::${buildCommit}::${headOid}`. For a fixed (build commit, HEAD oid) pair
84
+ * the ancestry relationship (is build an ancestor of HEAD? equal? unrelated?) is
85
+ * immutable, so the verdict can be cached indefinitely — it auto-invalidates the moment
86
+ * HEAD moves (new oid → new key). This removes the steady-state `cat-file` +
87
+ * `rev-parse` + `merge-base` spawns (2-3 per scope) when HEAD has not moved between
88
+ * probes. A value of `false` means "build is NOT a strict ancestor of HEAD" (current,
89
+ * ahead, or unrelated — no warning); `true` means a strict-ancestor relationship was
90
+ * proven for that pair.
91
+ */
92
+ const buildBehindAncestryCache = new Map<string, boolean>();
93
+
48
94
  /** Test seam: clear the last-known-good status cache between cases. */
49
95
  export function __resetGitStatusCacheForTests(): void {
50
96
  lastKnownGoodStatus.clear();
51
97
  changeImpactEvalCache.clear();
52
98
  changeImpactConfigCache.clear();
99
+ upstreamFetchedAt.clear();
100
+ buildBehindAncestryCache.clear();
53
101
  }
54
102
 
103
+ /** Alias matching the task's requested name. */
104
+ export const __resetGitStatusCache = __resetGitStatusCacheForTests;
105
+
55
106
  /** Test-only introspection: number of memoized Change Impact evaluations. */
56
107
  export function __changeImpactEvalCacheSizeForTests(): number {
57
108
  return changeImpactEvalCache.size;
@@ -77,6 +128,18 @@ export interface GitStatusOptions {
77
128
  * Callers should opt into this only for convergence-critical surfaces.
78
129
  */
79
130
  refreshUpstream?: boolean;
131
+ /**
132
+ * When true, bypass the C1 TTL result cache: always re-collect a fresh status
133
+ * (ignoring any cached entry younger than GIT_STATUS_CACHE_TTL_MS). The freshly
134
+ * collected result still UPDATES the cache so subsequent normal callers benefit.
135
+ *
136
+ * Mutating / decision callers (mesh_fast_forward preflight + post-merge re-read,
137
+ * refine submodule alignment pre/post status) MUST set this — acting on a stale
138
+ * ahead/behind or submodule sync verdict is the primary correctness hazard of the
139
+ * cache. Read-only/observe callers (mesh_status, git-monitor, reconcile) leave it
140
+ * unset and enjoy the dedup.
141
+ */
142
+ forceFresh?: boolean;
80
143
  /**
81
144
  * Test/override seam for the daemon build stamp used by the stale-build
82
145
  * detector. Production callers omit this so the real baked-in build commit
@@ -96,10 +159,25 @@ export interface GitStatusOptions {
96
159
  changeImpactConfig?: ChangeImpactConfig | null;
97
160
  }
98
161
 
162
+ /**
163
+ * C2: minimum interval between actual `git fetch` network calls per workspace. A
164
+ * refreshUpstream caller within this window does NOT re-fetch; it serves ahead/behind
165
+ * from the locally-re-read porcelain (which is always fresh every call). So
166
+ * refreshUpstream becomes "fetch if the local remote-tracking ref is stale" rather than
167
+ * "always pay a network round trip". Only the upstream ahead/behind can age up to this
168
+ * throttle; local working-tree status never does.
169
+ */
170
+ export const GIT_FETCH_THROTTLE_MS = 30_000;
171
+
172
+ /** Wall-clock of the last successful `git fetch` per workspace (C2 throttle gate). */
173
+ const upstreamFetchedAt = new Map<string, number>();
174
+
99
175
  interface GitUpstreamProbe {
100
176
  upstreamStatus: GitUpstreamFreshness;
101
177
  upstreamFetchedAt?: number;
102
178
  upstreamFetchError?: string;
179
+ /** True only when this probe performed an actual network fetch (porcelain re-read needed). */
180
+ didFetch?: boolean;
103
181
  }
104
182
 
105
183
  export async function getGitRepoStatus(
@@ -116,10 +194,26 @@ export async function getGitRepoStatus(
116
194
  const effectiveOptions: GitStatusOptions =
117
195
  options.timeoutMs === undefined ? { ...options, timeoutMs: GIT_STATUS_TIMEOUT_MS } : options;
118
196
 
197
+ const cacheKey = statusCacheKey(workspace, options);
198
+
199
+ // C1: happy-path TTL result cache. A non-forceFresh caller within the TTL window is
200
+ // served the last successfully-collected status for this exact option shape instead
201
+ // of re-shelling ~14 git processes. Only successful, fully-populated results are ever
202
+ // stored (see the .set below + the never-cache-error rule in the catch), so a cache
203
+ // hit can never serve an error/empty status.
204
+ if (!options.forceFresh) {
205
+ const cached = lastKnownGoodStatus.get(cacheKey);
206
+ if (cached && lastCheckedAt - cached.cachedAt < GIT_STATUS_CACHE_TTL_MS) {
207
+ return cached.status;
208
+ }
209
+ }
210
+
119
211
  try {
120
212
  const repo = await resolveGitRepository(workspace, effectiveOptions);
121
213
  const status = await collectGitRepoStatus(repo, includeSubmodules, lastCheckedAt, effectiveOptions);
122
- lastKnownGoodStatus.set(workspace, status);
214
+ // Cache the fresh, fully-populated success. forceFresh callers still refresh the
215
+ // cache so the next normal caller benefits from their freshly-collected status.
216
+ lastKnownGoodStatus.set(cacheKey, { status, cachedAt: lastCheckedAt });
123
217
  return status;
124
218
  } catch (error) {
125
219
  const gitError = error instanceof GitCommandError
@@ -130,7 +224,7 @@ export async function getGitRepoStatus(
130
224
  // a healthy node lose its repo identity and drop out of the mesh graph. Prefer the
131
225
  // last status we successfully collected for this workspace, re-stamped as stale.
132
226
  if (isTransientGitFailure(gitError)) {
133
- const cached = lastKnownGoodStatus.get(workspace);
227
+ const cached = lastKnownGoodStatus.get(cacheKey)?.status;
134
228
  if (cached) {
135
229
  return {
136
230
  ...cached,
@@ -157,7 +251,11 @@ async function collectGitRepoStatus(
157
251
 
158
252
  if (options.refreshUpstream) {
159
253
  upstreamProbe = await refreshTrackedUpstream(repo, parsed, options);
160
- if (upstreamProbe.upstreamStatus === 'fresh') {
254
+ // Re-read the porcelain (to pick up the updated ahead/behind) ONLY when this probe
255
+ // actually fetched. When the fetch was throttled (C2), the remote-tracking ref is
256
+ // unchanged, so the ahead/behind already parsed is still correct — re-reading would
257
+ // be a wasted spawn.
258
+ if (upstreamProbe.upstreamStatus === 'fresh' && upstreamProbe.didFetch) {
161
259
  parsed = await readPorcelainStatus(repo, options);
162
260
  }
163
261
  }
@@ -166,8 +264,11 @@ async function collectGitRepoStatus(
166
264
  const stashCount = await readStashCount(repo, options);
167
265
 
168
266
  let submodules: GitSubmoduleStatus[] | undefined;
267
+ let submoduleHeadOids = new Map<string, string>();
169
268
  if (includeSubmodules) {
170
- submodules = await getSubmoduleStatuses(repo, options);
269
+ const subResult = await getSubmoduleStatuses(repo, options);
270
+ submodules = subResult.submodules;
271
+ submoduleHeadOids = subResult.headOidByPath;
171
272
  }
172
273
  const submoduleDirty = (submodules || []).some(submodule => submodule.dirty || submodule.outOfSync || !!submodule.error);
173
274
  const dirty = parsed.staged + parsed.modified + parsed.untracked + parsed.deleted + parsed.renamed > 0
@@ -175,7 +276,10 @@ async function collectGitRepoStatus(
175
276
  || stashCount > 0
176
277
  || submoduleDirty;
177
278
 
178
- const daemonBuildBehind = await detectDaemonBuildBehind(repo, submodules, options);
279
+ const daemonBuildBehind = await detectDaemonBuildBehind(repo, submodules, options, {
280
+ rootHeadOid: parsed.headOid,
281
+ submoduleHeadOids,
282
+ });
179
283
 
180
284
  return {
181
285
  workspace: repo.workspace,
@@ -426,10 +530,18 @@ function resolveChangeImpactConfigForRepo(
426
530
  return { config, sourceKey: loaded.sourceKey };
427
531
  }
428
532
 
533
+ interface DaemonBuildBehindHeadOids {
534
+ /** Root repo HEAD oid from porcelain `# branch.oid` (avoids a rev-parse spawn). */
535
+ rootHeadOid: string | null;
536
+ /** Per-submodule-path actual HEAD oid (already read during submodule collection). */
537
+ submoduleHeadOids: Map<string, string>;
538
+ }
539
+
429
540
  async function detectDaemonBuildBehind(
430
541
  repo: ResolvedGitRepo,
431
542
  submodules: GitSubmoduleStatus[] | undefined,
432
543
  options: GitStatusOptions,
544
+ headOids: DaemonBuildBehindHeadOids = { rootHeadOid: null, submoduleHeadOids: new Map() },
433
545
  ): Promise<DaemonBuildBehind | undefined> {
434
546
  const build = options.daemonBuildInfo ?? getDaemonBuildInfo();
435
547
  if (!build.commit || build.commit === 'unknown') return undefined;
@@ -440,23 +552,60 @@ async function detectDaemonBuildBehind(
440
552
  // Check the root repo first, then each submodule. The daemon build commit is
441
553
  // baked from the daemon-core (oss submodule) HEAD, so on an adhdev
442
554
  // superproject worktree the match is expected on the `oss` submodule, not the
443
- // root — checking both keeps the helper repo-agnostic.
444
- const scopes: Array<{ scope: string; repoPath: string }> = [
445
- { scope: 'root', repoPath: repo.repoRoot || repo.workspace },
555
+ // root — checking both keeps the helper repo-agnostic. The known HEAD oid (from
556
+ // porcelain for root, from the submodule rev-parse already paid during collection)
557
+ // is threaded in so the C3 ancestry cache can short-circuit before any spawn.
558
+ const scopes: Array<{ scope: string; repoPath: string; knownHeadOid: string | null }> = [
559
+ { scope: 'root', repoPath: repo.repoRoot || repo.workspace, knownHeadOid: headOids.rootHeadOid },
446
560
  ];
447
561
  for (const sub of submodules || []) {
448
- if (sub.repoPath && !sub.error) scopes.push({ scope: sub.path, repoPath: sub.repoPath });
562
+ if (sub.repoPath && !sub.error) {
563
+ scopes.push({ scope: sub.path, repoPath: sub.repoPath, knownHeadOid: headOids.submoduleHeadOids.get(sub.path) ?? null });
564
+ }
449
565
  }
450
566
 
451
- for (const { scope, repoPath } of scopes) {
567
+ for (const { scope, repoPath, knownHeadOid } of scopes) {
452
568
  try {
453
- // Build commit must be a real object in THIS repo, else it's a different repo.
454
- await runGit(repoPath, ['cat-file', '-e', `${build.commit}^{commit}`], options);
455
- const headResult = await runGit(repoPath, ['rev-parse', 'HEAD'], options);
456
- const head = headResult.stdout.trim();
457
- if (!head || head === build.commit) continue;
458
- // Strict ancestor: build commit is reachable from HEAD but is not HEAD.
459
- await runGit(repoPath, ['merge-base', '--is-ancestor', build.commit, 'HEAD'], options);
569
+ // C3 fast path: if we already know this scope's HEAD oid and have a cached
570
+ // ancestry verdict for (repoPath, buildCommit, headOid), reuse it. A `false`
571
+ // verdict (not a strict ancestor current/ahead/unrelated) lets us skip the
572
+ // cat-file + merge-base spawns entirely; the verdict auto-invalidates when HEAD
573
+ // moves (new oid new key). A `true` verdict still needs the diff
574
+ // classification below, which is itself memoized on the same head oid.
575
+ let head = knownHeadOid;
576
+ const ancestryKey = head ? `${repoPath}::${build.commit}::${head}` : null;
577
+ if (ancestryKey) {
578
+ const cachedVerdict = buildBehindAncestryCache.get(ancestryKey);
579
+ if (cachedVerdict === false) continue;
580
+ if (cachedVerdict === undefined) {
581
+ if (head === build.commit) {
582
+ buildBehindAncestryCache.set(ancestryKey, false);
583
+ continue;
584
+ }
585
+ // Build commit must be a real object in THIS repo, else it's a different repo.
586
+ await runGit(repoPath, ['cat-file', '-e', `${build.commit}^{commit}`], options);
587
+ try {
588
+ await runGit(repoPath, ['merge-base', '--is-ancestor', build.commit, 'HEAD'], options);
589
+ } catch {
590
+ // Not a strict ancestor — cache the negative verdict so the next probe at
591
+ // this same HEAD short-circuits, then move on to the next scope.
592
+ buildBehindAncestryCache.set(ancestryKey, false);
593
+ continue;
594
+ }
595
+ buildBehindAncestryCache.set(ancestryKey, true);
596
+ }
597
+ // cachedVerdict === true (or just proven true) → fall through to classification.
598
+ } else {
599
+ // No known HEAD oid for this scope — fall back to the original probe (resolve
600
+ // HEAD via rev-parse). This keeps correctness when porcelain/submodule oid is
601
+ // unavailable; the verdict is still cached once HEAD is known.
602
+ await runGit(repoPath, ['cat-file', '-e', `${build.commit}^{commit}`], options);
603
+ const headResult = await runGit(repoPath, ['rev-parse', 'HEAD'], options);
604
+ head = headResult.stdout.trim();
605
+ if (!head || head === build.commit) continue;
606
+ await runGit(repoPath, ['merge-base', '--is-ancestor', build.commit, 'HEAD'], options);
607
+ buildBehindAncestryCache.set(`${repoPath}::${build.commit}::${head}`, true);
608
+ }
460
609
  // No throw → build commit IS an ancestor of HEAD → daemon is behind.
461
610
  // Inspect WHICH packages changed in buildCommit..HEAD. A daemon rebuild/restart
462
611
  // is only actually required when a daemon-runtime package changed; if only web /
@@ -465,6 +614,7 @@ async function detectDaemonBuildBehind(
465
614
  // verdict is memoized on (repoPath, buildCommit, head, config) to suppress
466
615
  // re-evaluation on the hot status path.
467
616
  const evalKey = `${repoPath}${build.commit}${head}${configKey}`;
617
+ if (!head) continue; // proven non-empty oid here; every other branch above continued
468
618
  let evaluated = changeImpactEvalCache.get(evalKey);
469
619
  if (!evaluated) {
470
620
  evaluated = await classifyDaemonBuildChange(repoPath, build.commit, options, policy);
@@ -509,6 +659,8 @@ async function detectDaemonBuildBehind(
509
659
 
510
660
  interface ParsedPorcelainStatus {
511
661
  branch: string | null;
662
+ /** Full HEAD object id from `# branch.oid`, or null when detached/unborn. */
663
+ headOid: string | null;
512
664
  upstream: string | null;
513
665
  ahead: number;
514
666
  behind: number;
@@ -540,6 +692,20 @@ async function refreshTrackedUpstream(
540
692
  return { upstreamStatus: 'no_upstream' };
541
693
  }
542
694
 
695
+ // C2: throttle the actual network fetch. If we fetched this workspace within
696
+ // GIT_FETCH_THROTTLE_MS, skip the fetch and serve ahead/behind from the local
697
+ // porcelain (re-read fresh every call by the caller). forceFresh callers
698
+ // (convergence-critical: ff / refine) always re-fetch — they need true upstream.
699
+ const now = Date.now();
700
+ const lastFetch = upstreamFetchedAt.get(repo.workspace);
701
+ if (!options.forceFresh && lastFetch !== undefined && now - lastFetch < GIT_FETCH_THROTTLE_MS) {
702
+ return {
703
+ upstreamStatus: 'fresh',
704
+ upstreamFetchedAt: lastFetch,
705
+ didFetch: false,
706
+ };
707
+ }
708
+
543
709
  const remoteName = (await readBranchRemote(repo, parsed.branch, options)) ?? inferRemoteName(parsed.upstream);
544
710
  if (!remoteName) {
545
711
  return {
@@ -550,9 +716,12 @@ async function refreshTrackedUpstream(
550
716
 
551
717
  try {
552
718
  await runGit(repo, ['fetch', '--quiet', '--prune', '--no-tags', remoteName], options);
719
+ const fetchedAt = Date.now();
720
+ upstreamFetchedAt.set(repo.workspace, fetchedAt);
553
721
  return {
554
722
  upstreamStatus: 'fresh',
555
- upstreamFetchedAt: Date.now(),
723
+ upstreamFetchedAt: fetchedAt,
724
+ didFetch: true,
556
725
  };
557
726
  } catch (error) {
558
727
  return {
@@ -589,6 +758,7 @@ function formatGitError(error: unknown): string {
589
758
  export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
590
759
  const parsed: ParsedPorcelainStatus = {
591
760
  branch: null,
761
+ headOid: null,
592
762
  upstream: null,
593
763
  ahead: 0,
594
764
  behind: 0,
@@ -603,6 +773,13 @@ export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
603
773
  for (const line of output.split('\n')) {
604
774
  if (!line) continue;
605
775
 
776
+ if (line.startsWith('# branch.oid ')) {
777
+ const oid = line.slice('# branch.oid '.length).trim();
778
+ // `(initial)` is git's sentinel for an unborn HEAD — not a real object id.
779
+ parsed.headOid = oid && oid !== '(initial)' && /^[0-9a-f]{7,64}$/.test(oid) ? oid : null;
780
+ continue;
781
+ }
782
+
606
783
  if (line.startsWith('# branch.head ')) {
607
784
  const branch = line.slice('# branch.head '.length).trim();
608
785
  parsed.branch = branch && branch !== '(detached)' ? branch : null;
@@ -719,11 +896,17 @@ function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommand
719
896
 
720
897
  // ─── Submodule Status ───────────────────────────
721
898
 
899
+ interface SubmoduleStatusResult {
900
+ submodules: GitSubmoduleStatus[];
901
+ /** Actual checked-out HEAD oid per submodule path (for the C3 ancestry cache key). */
902
+ headOidByPath: Map<string, string>;
903
+ }
904
+
722
905
  async function getSubmoduleStatuses(
723
906
  repo: ResolvedGitRepo,
724
907
  options: GitStatusOptions,
725
- ): Promise<GitSubmoduleStatus[]> {
726
- if (!repo.repoRoot) return [];
908
+ ): Promise<SubmoduleStatusResult> {
909
+ if (!repo.repoRoot) return { submodules: [], headOidByPath: new Map() };
727
910
 
728
911
  try {
729
912
  // Do NOT shell out to `git submodule status`. That porcelain wrapper is a
@@ -743,11 +926,11 @@ async function getSubmoduleStatuses(
743
926
  // (conflict) prefix is surfaced separately via the superproject porcelain
744
927
  // status that the caller already parses, and a conflicted submodule's own
745
928
  // status read here also flags it dirty — so no row is lost.
746
- const submodules = await deriveSubmoduleGitlinkStatuses(repo, options);
929
+ const { submodules, headOidByPath } = await deriveSubmoduleGitlinkStatuses(repo, options);
747
930
  await Promise.all(submodules.map(submodule => enrichSubmoduleWorktreeStatus(repo, submodule, options)));
748
- return submodules;
931
+ return { submodules, headOidByPath };
749
932
  } catch {
750
- return [];
933
+ return { submodules: [], headOidByPath: new Map() };
751
934
  }
752
935
  }
753
936
 
@@ -760,11 +943,12 @@ async function getSubmoduleStatuses(
760
943
  async function deriveSubmoduleGitlinkStatuses(
761
944
  repo: ResolvedGitRepo,
762
945
  options: GitStatusOptions,
763
- ): Promise<GitSubmoduleStatus[]> {
764
- if (!repo.repoRoot) return [];
946
+ ): Promise<SubmoduleStatusResult> {
947
+ if (!repo.repoRoot) return { submodules: [], headOidByPath: new Map() };
765
948
  const paths = await readSubmodulePaths(repo, options);
766
949
  const ignoreSet = new Set(options.submoduleIgnorePaths || []);
767
950
  const lastCheckedAt = Date.now();
951
+ const headOidByPath = new Map<string, string>();
768
952
 
769
953
  const entries = await Promise.all(
770
954
  paths
@@ -773,6 +957,10 @@ async function deriveSubmoduleGitlinkStatuses(
773
957
  const repoPath = repo.repoRoot + '/' + path;
774
958
  const expected = await readGitlinkExpectedSha(repo, path, options);
775
959
  const actual = await readSubmoduleHeadSha(repo, repoPath, options);
960
+ // Reuse the actual checked-out HEAD oid for the C3 build-behind ancestry cache
961
+ // key — it's the submodule HEAD the daemon build commit is tested against, and
962
+ // we already paid this rev-parse for the outOfSync check, so no extra spawn.
963
+ if (actual) headOidByPath.set(path, actual);
776
964
  // Uninitialized / no checked-out HEAD reproduces `git submodule status`'s
777
965
  // `-` prefix; a present-but-divergent HEAD reproduces the `+` prefix.
778
966
  const outOfSync = actual === null
@@ -790,7 +978,7 @@ async function deriveSubmoduleGitlinkStatuses(
790
978
  };
791
979
  }),
792
980
  );
793
- return entries;
981
+ return { submodules: entries, headOidByPath };
794
982
  }
795
983
 
796
984
  /** Read submodule paths from `.gitmodules` via plumbing (no shell wrapper). */
package/src/index.ts CHANGED
@@ -247,7 +247,7 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
247
247
  export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
248
248
 
249
249
  // ── Mesh Work Queue (GUPP) ──
250
- export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
250
+ export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, isTaskReadonly, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
251
251
  export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
252
252
  export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, pruneStaleDirectDispatches, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
253
253
  export type { StaleDirectPruneClassification, StaleDirectPruneResult, PruneStaleDirectDispatchesOptions } from './mesh/mesh-active-work.js';
@@ -87,7 +87,11 @@ type MeshFastForwardBase = Pick<
87
87
  'workspace' | 'mode' | 'dryRun' | 'updateSubmodules' | 'plannedSteps' | 'trigger'
88
88
  > & Pick<Partial<MeshFastForwardResult>, 'nodeId' | 'meshId'>;
89
89
 
90
- const STATUS_OPTIONS = { refreshUpstream: true, includeSubmodules: true, timeoutMs: 15_000 } as const;
90
+ // forceFresh: fast-forward is a mutating/decision path its preflight blockers and its
91
+ // post-merge re-reads MUST see live git state, never a TTL-cached status from a
92
+ // concurrent reconcile/mesh_status probe. It also bypasses the fetch throttle so
93
+ // ahead/behind reflects a true upstream at decision time.
94
+ const STATUS_OPTIONS = { refreshUpstream: true, includeSubmodules: true, timeoutMs: 15_000, forceFresh: true } as const;
91
95
 
92
96
  export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promise<MeshFastForwardResult> {
93
97
  const workspace = typeof args.workspace === 'string' ? args.workspace.trim() : '';