@adhdev/daemon-core 1.0.18-rc.10 → 1.0.18-rc.11

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 (33) hide show
  1. package/dist/index.js +690 -35
  2. package/dist/index.js.map +1 -1
  3. package/dist/index.mjs +690 -35
  4. package/dist/index.mjs.map +1 -1
  5. package/dist/mesh/mesh-active-work.d.ts +1 -1
  6. package/dist/mesh/mesh-ledger.d.ts +1 -1
  7. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  8. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  9. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  10. package/dist/providers/cli-provider-instance-types.d.ts +2 -0
  11. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  12. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  13. package/package.json +3 -3
  14. package/src/commands/high-family/mesh-events.ts +13 -2
  15. package/src/commands/med-family/mesh-queue.ts +74 -1
  16. package/src/commands/router-refine.ts +71 -4
  17. package/src/commands/router.ts +8 -0
  18. package/src/mesh/coordinator-prompt.ts +2 -1
  19. package/src/mesh/mesh-active-work.ts +11 -1
  20. package/src/mesh/mesh-event-classify.ts +19 -0
  21. package/src/mesh/mesh-event-forwarding.ts +46 -4
  22. package/src/mesh/mesh-events-utils.ts +42 -0
  23. package/src/mesh/mesh-ledger.ts +5 -0
  24. package/src/mesh/mesh-node-identity.ts +49 -0
  25. package/src/mesh/mesh-queue-assignment.ts +18 -1
  26. package/src/mesh/mesh-reconcile-loop.ts +6 -1
  27. package/src/mesh/mesh-refine-gates.ts +300 -0
  28. package/src/mesh/mesh-work-queue.ts +85 -2
  29. package/src/providers/cli-provider-instance-types.ts +28 -0
  30. package/src/providers/cli-provider-instance.ts +90 -15
  31. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  32. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  33. package/src/providers/types/interactive-prompt.ts +77 -0
@@ -1071,10 +1071,44 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1071
1071
  // C2: prefer an exact taskId match when the completion event carries one —
1072
1072
  // it's immune to coordinator↔worker clock skew that can hide the assigned row.
1073
1073
  const eventTaskId = readNonEmptyString(args.metadataEvent.taskId) || undefined;
1074
- const task = updateSessionTaskStatus(args.meshId, sessionId, outcome, {
1075
- occurredAt: occurredAtMs != null ? new Date(occurredAtMs).toISOString() : undefined,
1076
- taskId: eventTaskId,
1077
- });
1074
+ // WEAK-QUEUE-TENTATIVE (early-completed safety net, symmetric with the direct-dispatch
1075
+ // `tentativeIfDirect` guard below): a `completed` outcome whose evidence is WEAK — a
1076
+ // false-idle / missing_final_assistant emit (the CANON-C decoupled-immediate path that
1077
+ // stamps evidenceLevel=insufficient) — must NOT hard-flip a matched QUEUE row to
1078
+ // terminal. Until this net, only DIRECT dispatches were kept tentative on weak evidence;
1079
+ // a queue-claimed task's session flipped its row to 'completed' unconditionally, so a
1080
+ // worker that dropped to idle ~13s in with no final assistant closed the task early even
1081
+ // though it never produced an answer. Leaving the row 'assigned' hands it to the reconcile
1082
+ // loop's proven net (PHASE 4 records the GENUINE completion once the transcript lands via
1083
+ // findTerminalLedgerEvidenceForTask; PHASE 2.5 reclaims a truly-stranded row back to
1084
+ // 'pending' for re-dispatch, bounded by MAX_STRANDED_RECLAIMS) — never a permanent wedge.
1085
+ //
1086
+ // CRUCIAL SCOPE: this covers only the PREMATURE decoupled-immediate emit
1087
+ // (emittedAfterFinalizationTimeout !== true). A weak completion that already waited out the
1088
+ // full 30s COMPLETED_FINALIZATION_MAX_WAIT_MS window (emittedAfterFinalizationTimeout=true)
1089
+ // is a GENUINE — if answerless — terminal: the worker exhausted its finalization wait and
1090
+ // is done, so it flips 'completed' as before (a tool-only turn that never produced an
1091
+ // assistant bubble must still close its task). A `failed` outcome or a completion with
1092
+ // genuine evidence flips terminal as before too.
1093
+ const completionDiagnostic = args.metadataEvent.completionDiagnostic && typeof args.metadataEvent.completionDiagnostic === 'object'
1094
+ ? args.metadataEvent.completionDiagnostic as Record<string, unknown>
1095
+ : undefined;
1096
+ const emittedAfterFinalizationTimeout = completionDiagnostic?.emittedAfterFinalizationTimeout === true;
1097
+ const weakCompleted = outcome === 'completed'
1098
+ && isWeakCompletionEvidence(args.metadataEvent)
1099
+ && !emittedAfterFinalizationTimeout;
1100
+ const task = weakCompleted
1101
+ ? updateSessionTaskStatus(args.meshId, sessionId, 'assigned', {
1102
+ occurredAt: occurredAtMs != null ? new Date(occurredAtMs).toISOString() : undefined,
1103
+ taskId: eventTaskId,
1104
+ })
1105
+ : updateSessionTaskStatus(args.meshId, sessionId, outcome, {
1106
+ occurredAt: occurredAtMs != null ? new Date(occurredAtMs).toISOString() : undefined,
1107
+ taskId: eventTaskId,
1108
+ });
1109
+ if (weakCompleted && task) {
1110
+ LOG.info('MeshQueue', `Weak completion (${readNonEmptyString(args.metadataEvent.evidenceLevel) || 'missing_final_assistant'}) kept queue task ${task.id} tentative (session ${sessionId}); reconcile owns the genuine terminal`);
1111
+ }
1078
1112
  // Fix A (early-terminal prevention): a false-idle completion (no confirmed final
1079
1113
  // assistant) for a DIRECT dispatch — i.e. no work-queue row matched — must not flip the
1080
1114
  // dispatch row terminal. Leaving it active lets the reconcile loop (PHASE 4) re-read the
@@ -1754,6 +1788,14 @@ export function buildRelayMetadataEvent(payload: Record<string, unknown>): Recor
1754
1788
  retryOfJobId: readNonEmptyString(payload.retryOfJobId),
1755
1789
  ...(relayModalMessage ? { modalMessage: relayModalMessage } : {}),
1756
1790
  ...(relayModalButtons && relayModalButtons.length > 0 ? { modalButtons: relayModalButtons } : {}),
1791
+ // agent:waiting_choice (mission f1d25e11): carry the FULL structured question
1792
+ // payload across the machine boundary so a REMOTE worker's AskUserQuestion reaches
1793
+ // the coordinator with every question + option intact — the coordinator renders
1794
+ // these and answers with mesh_answer_question. The local in-process forward path
1795
+ // preserves the whole event for free; this mirrors the fields for the remote relay.
1796
+ ...(payload.interactivePrompt && typeof payload.interactivePrompt === 'object' && !Array.isArray(payload.interactivePrompt) ? { interactivePrompt: payload.interactivePrompt } : {}),
1797
+ ...(readNonEmptyString(payload.promptId) ? { promptId: readNonEmptyString(payload.promptId) } : {}),
1798
+ ...(payload.multiSelect === true ? { multiSelect: true } : {}),
1757
1799
  ...(payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result) ? { result: payload.result } : {}),
1758
1800
  ...(payload.completionDiagnostic && typeof payload.completionDiagnostic === 'object' && !Array.isArray(payload.completionDiagnostic) ? { completionDiagnostic: payload.completionDiagnostic } : {}),
1759
1801
  ...(payload.workerResult && typeof payload.workerResult === 'object' && !Array.isArray(payload.workerResult) ? { workerResult: payload.workerResult } : {}),
@@ -330,6 +330,48 @@ export function buildMeshSystemMessage(args: {
330
330
  if (args.event === 'agent:waiting_approval') {
331
331
  return `[System] ${args.nodeLabel} is waiting for approval to proceed${metadata}. You may use mesh_read_chat and mesh_approve to handle it.`;
332
332
  }
333
+ if (args.event === 'agent:waiting_choice') {
334
+ // A multi-choice QUESTION (AskUserQuestion) — NOT an approval. It is answered with
335
+ // mesh_answer_question (never mesh_approve). Surface the question text + choices
336
+ // inline so the coordinator can decide without a mesh_read_chat round-trip, and
337
+ // name the correct tool + promptId (mission f1d25e11).
338
+ const prompt = args.metadataEvent.interactivePrompt as
339
+ | { promptId?: unknown; questions?: Array<Record<string, unknown>> }
340
+ | undefined;
341
+ const promptId = readNonEmptyString(args.metadataEvent.promptId)
342
+ || (prompt && readNonEmptyString(prompt.promptId));
343
+ const lines: string[] = [
344
+ `[System] ${args.nodeLabel} is asking a question and is waiting for your answer${metadata}.`,
345
+ ];
346
+ const questions = Array.isArray(prompt?.questions) ? prompt!.questions! : [];
347
+ if (questions.length > 0) {
348
+ for (const q of questions) {
349
+ const header = readNonEmptyString(q.header);
350
+ const question = readNonEmptyString(q.question);
351
+ const multiSelect = q.multiSelect === true;
352
+ if (question) {
353
+ lines.push(`\n**${header ? `${header}: ` : ''}${question}**${multiSelect ? ' (select one or more)' : ''}`);
354
+ }
355
+ const options = Array.isArray(q.options) ? q.options : [];
356
+ options.forEach((opt, i) => {
357
+ const record = (opt && typeof opt === 'object') ? opt as Record<string, unknown> : {};
358
+ const label = readNonEmptyString(record.label);
359
+ if (!label) return;
360
+ const description = readNonEmptyString(record.description);
361
+ lines.push(` ${i + 1}. ${label}${description ? ` — ${description}` : ''}`);
362
+ });
363
+ }
364
+ } else {
365
+ const modalMessage = readNonEmptyString(args.metadataEvent.modalMessage);
366
+ if (modalMessage) lines.push(`\n${modalMessage}`);
367
+ }
368
+ lines.push(
369
+ `\nAnswer with mesh_answer_question(node_id, session_id${promptId ? `, promptId: "${promptId}"` : ''}, answers). ` +
370
+ `Do NOT use mesh_approve — that only resolves yes/no consent modals, not a question. ` +
371
+ `Use mesh_read_chat once if you need the full context first.`,
372
+ );
373
+ return lines.join('\n');
374
+ }
333
375
  if (args.event === 'agent:stopped') {
334
376
  const rc = args.recoveryContext;
335
377
  if (rc && rc.consecutiveNodeFailures > 0) {
@@ -33,6 +33,11 @@ export type MeshLedgerKind =
33
33
  | 'task_failed'
34
34
  | 'task_stalled'
35
35
  | 'task_approval_needed'
36
+ // A worker is parked on an AskUserQuestion multi-choice prompt (waiting_choice) —
37
+ // distinct from task_approval_needed (a yes/no tool-consent modal). The coordinator
38
+ // answers a question with mesh_answer_question, never mesh_approve (mission f1d25e11).
39
+ // payload carries the full InteractivePrompt (promptId + questions + options).
40
+ | 'task_question_pending'
36
41
  | 'p2p_dispatch_failed'
37
42
  | 'session_launched'
38
43
  | 'session_auto_launch'
@@ -907,6 +907,55 @@ export function isMeshNodeHealthLaunchable(node: any): boolean {
907
907
  return health === 'online' || health === 'unknown';
908
908
  }
909
909
 
910
+ /** Resolve the git telemetry object a node carries, in the same precedence
911
+ * resolveEffectiveMeshNodeHealth uses: a fresh `node.git`, else the cached inline
912
+ * status git (`node.cachedStatus.git`). Returns an empty record when neither is
913
+ * present (no telemetry). */
914
+ function resolveEffectiveNodeGit(node: any): Record<string, any> {
915
+ const git = readObjectRecord(node?.git);
916
+ if (Object.keys(git).length > 0) return git;
917
+ const cachedStatus = readObjectRecord(node?.cachedStatus);
918
+ return readObjectRecord(cachedStatus.git);
919
+ }
920
+
921
+ /**
922
+ * Launch FRESHNESS gate — distinct from the health gate above.
923
+ *
924
+ * `deriveMeshNodeHealthFromGit` reports a clean-tree node with `behind > 0` as
925
+ * 'online', so the health gate (isMeshNodeHealthLaunchable) happily lets a STALE
926
+ * node — one whose branch is N commits behind its upstream — win auto-launch fitness
927
+ * routing and run a fresh worker against out-of-date code. `behind` is deliberately
928
+ * NOT folded into deriveMeshNodeHealthFromGit because "behind" is not universally
929
+ * unhealthy (the MAGI planner and other callers share that resolver); it is only
930
+ * unhealthy for *spawning new work*. This gate encodes exactly that launch-time axis.
931
+ *
932
+ * Returns false (NOT fresh → skip / de-rank) only when git telemetry is PRESENT and
933
+ * proves staleness:
934
+ * - behind count exceeds `maxBehind` (default 0 — any behind blocks), OR
935
+ * - a submodule is out of sync (gitlink points off upstream — cannot be caught up
936
+ * by a simple worktree ff and would launch against a mismatched submodule).
937
+ *
938
+ * When telemetry is absent it returns true (fresh), preserving the online/unknown-pass
939
+ * philosophy: we never block on missing data, only on data that proves the node stale.
940
+ */
941
+ export function isMeshNodeFreshEnoughToLaunch(node: any, opts?: { maxBehind?: number }): boolean {
942
+ const git = resolveEffectiveNodeGit(node);
943
+ // No git telemetry at all → cannot prove stale → do not block.
944
+ if (Object.keys(git).length === 0) return true;
945
+ // Not a git repo / no branch: leave this to the health gate (it returns 'degraded'
946
+ // and blocks there); freshness has nothing to add.
947
+ if (readBooleanValue(git.isGitRepo) === false) return true;
948
+ const submoduleDrift = getGitSubmoduleDriftState(git);
949
+ if (submoduleDrift.outOfSync) return false;
950
+ const behind = readNumberValue(git.behind);
951
+ // No behind datum reported → treat as fresh (don't infer staleness from absence).
952
+ if (behind === undefined) return true;
953
+ const maxBehind = Number.isFinite(opts?.maxBehind as number) && (opts?.maxBehind as number) >= 0
954
+ ? Math.floor(opts!.maxBehind as number)
955
+ : 0;
956
+ return behind <= maxBehind;
957
+ }
958
+
910
959
  function readMeshNodeLabel(status: Record<string, unknown>, node: any): string {
911
960
  return readStringValue(status.nodeId, normalizeMeshNodeId(node)) ?? 'unknown';
912
961
  }
@@ -19,7 +19,7 @@ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalD
19
19
  import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
20
20
  import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
21
21
  import { readNonEmptyString } from './mesh-events-utils.js';
22
- import { readMeshNodeDaemonId, isMeshNodeHealthLaunchable } from './mesh-node-identity.js';
22
+ import { readMeshNodeDaemonId, isMeshNodeHealthLaunchable, isMeshNodeFreshEnoughToLaunch } from './mesh-node-identity.js';
23
23
  import { queuePendingMeshCoordinatorEvent, retractPendingDispatchBlockedEvent, drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
24
24
  import { isWorktreeBootstrapStaleRunning, shouldDeferDispatchForBootstrap } from './worktree-bootstrap-config.js';
25
25
  import { isWithinCloneBootstrapGrace } from './mesh-clone-grace.js';
@@ -1987,6 +1987,11 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1987
1987
  }
1988
1988
  if (!pending.length) return false;
1989
1989
 
1990
+ // Launch-freshness threshold: reuse the auto-fast-forward policy's maxBehind (default
1991
+ // 0 → any behind blocks) so the launch gate and the repair path agree on how far
1992
+ // behind is tolerable. Resolved once per pass and shared across every candidate node.
1993
+ const freshnessGate = { maxBehind: resolveAutoFastForwardPolicy(mesh).maxBehind };
1994
+
1990
1995
  // Write cap + read-only cap resolved through the shared helpers from the
1991
1996
  // MACHINE-LOCAL stored mesh policy (no repo-file overlay). These are the same
1992
1997
  // resolvers the observability projection uses, so the enforced and exposed
@@ -2210,6 +2215,18 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
2210
2215
  markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_not_launch_ready', nodeId });
2211
2216
  continue;
2212
2217
  }
2218
+ // FRESHNESS gate (distinct from the health gate above): a clean-tree node that
2219
+ // is `behind` its upstream reads as 'online' and passes isLaunchableNode, so
2220
+ // without this it could win fitness routing and run a fresh worker against
2221
+ // stale code. Skip a node whose git telemetry proves it stale (behind >
2222
+ // maxBehind, or a submodule out of sync). Reuse the auto-fast-forward policy's
2223
+ // maxBehind threshold so "how far behind is tolerable" is configured in ONE
2224
+ // place. Telemetry-absent nodes pass (never block on missing data). The 4s
2225
+ // reconcile retries once the node's auto-ff repair path catches it up.
2226
+ if (!isMeshNodeFreshEnoughToLaunch(node, freshnessGate)) {
2227
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_stale_behind_upstream', nodeId });
2228
+ continue;
2229
+ }
2213
2230
  const launchTarget = resolveAutoLaunchTarget(components, node);
2214
2231
  if (launchTarget.mode === 'skip') {
2215
2232
  // Remote node we can't reach (no transport / no coordinator daemonId).
@@ -754,7 +754,12 @@ function assignedRowLiveStatusIsAwaitingApproval(
754
754
  ): boolean {
755
755
  if (!nodeId || !sessionId) return false;
756
756
  try {
757
- return sessionStatusFromNodes(mesh.nodes, nodeId, sessionId).status === 'awaiting_approval';
757
+ // Both blocked-on-input states hold the row: an approval (waiting_approval) and a
758
+ // question (awaiting_choice) legitimately park the worker awaiting the coordinator's
759
+ // mesh_approve / mesh_answer_question, so neither should accrue the reclaim streak
760
+ // (mission f1d25e11 extends the APPROVAL-INBOX-BLINDSPOT guard to questions).
761
+ const liveStatus = sessionStatusFromNodes(mesh.nodes, nodeId, sessionId).status;
762
+ return liveStatus === 'awaiting_approval' || liveStatus === 'awaiting_choice';
758
763
  } catch {
759
764
  return false;
760
765
  }
@@ -1200,6 +1200,306 @@ export function collectFastForwardGitlinkPaths(repoRoot: string, baseHead: strin
1200
1200
  });
1201
1201
  }
1202
1202
 
1203
+ /**
1204
+ * Whether both commits exist locally in the submodule repo AND neither is an
1205
+ * ancestor of the other — i.e. a genuine sibling divergence off a shared merge
1206
+ * base. `baseCommit` ancestor-of `branchCommit` (strict fast-forward) returns
1207
+ * false: that case needs no rebase (the gate excludes it). Equal commits, a
1208
+ * missing commit, or a non-submodule path all return false (nothing to converge
1209
+ * / ambiguity stays "not divergent").
1210
+ */
1211
+ function isSubmoduleDivergedSibling(submoduleRepoPath: string, baseCommit: string, branchCommit: string): boolean {
1212
+ if (!baseCommit || !branchCommit || baseCommit === branchCommit) return false;
1213
+ try {
1214
+ if (!fs.existsSync(submoduleRepoPath)) return false;
1215
+ execFileSync(GIT, ['cat-file', '-e', `${baseCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
1216
+ execFileSync(GIT, ['cat-file', '-e', `${branchCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
1217
+ } catch {
1218
+ return false; // one of the commits is not available locally — cannot judge divergence
1219
+ }
1220
+ // base ancestor-of branch ⇒ pure fast-forward, not a sibling divergence.
1221
+ try {
1222
+ execFileSync(GIT, ['merge-base', '--is-ancestor', baseCommit, branchCommit], { cwd: submoduleRepoPath, stdio: 'ignore' });
1223
+ return false;
1224
+ } catch { /* not a fast-forward → keep checking */ }
1225
+ // branch ancestor-of base ⇒ branch is strictly behind (base already contains
1226
+ // it); no branch-side commits to replay, not our case.
1227
+ try {
1228
+ execFileSync(GIT, ['merge-base', '--is-ancestor', branchCommit, baseCommit], { cwd: submoduleRepoPath, stdio: 'ignore' });
1229
+ return false;
1230
+ } catch { /* neither ancestor of the other → genuine divergence */ }
1231
+ // Require a real shared merge base so the rebase has a sane replay range.
1232
+ try {
1233
+ const mb = execFileSync(GIT, ['merge-base', baseCommit, branchCommit], { cwd: submoduleRepoPath, encoding: 'utf8' }).trim();
1234
+ return !!mb;
1235
+ } catch {
1236
+ return false;
1237
+ }
1238
+ }
1239
+
1240
+ export type SubmoduleGitlinkConvergeResult = {
1241
+ /** True when at least one diverged gitlink submodule was rebased onto its base-side commit. */
1242
+ converged: boolean;
1243
+ /**
1244
+ * Set when convergence was declined/aborted (fail-safe → caller keeps the
1245
+ * original defer→blocked_review path). One of:
1246
+ * no_changed_gitlinks — no gitlink differs base↔branch
1247
+ * not_diverged — the gitlink is ff/behind/equal (gate handles it)
1248
+ * rebase_conflict — replaying branch-side onto base-side hit a real
1249
+ * content conflict inside the submodule (aborted)
1250
+ */
1251
+ reason?: string;
1252
+ /**
1253
+ * Converged gitlinks: path → the rebased submodule commit (SUBNEW) that the
1254
+ * root rebase must resolve the gitlink conflict to. Only populated for paths
1255
+ * whose submodule was successfully rebased (base-side is now a strict ancestor).
1256
+ */
1257
+ resolutions: Array<{ path: string; baseCommit: string; branchCommit: string; rebasedCommit: string }>;
1258
+ /** Per-path outcome for observability/logging. */
1259
+ gitlinks: Array<{
1260
+ path: string;
1261
+ baseCommit?: string;
1262
+ branchCommit?: string;
1263
+ rebasedCommit?: string;
1264
+ action: 'rebased' | 'skipped_not_diverged' | 'rebase_conflict';
1265
+ }>;
1266
+ };
1267
+
1268
+ /**
1269
+ * Best-effort: ensure `commit` exists in the submodule repo at `submoduleRepoPath`
1270
+ * by fetching it from the base repo's submodule checkout (a sibling working copy on
1271
+ * the same machine) when it is missing. A no-op when the commit is already present
1272
+ * or when the source path does not exist. Never throws — a fetch failure just
1273
+ * leaves the commit missing and the caller's ancestry check declines to converge.
1274
+ */
1275
+ function ensureSubmoduleCommitLocal(submoduleRepoPath: string, baseSubmoduleRepoPath: string, commit: string): void {
1276
+ if (!commit) return;
1277
+ try {
1278
+ execFileSync(GIT, ['cat-file', '-e', `${commit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
1279
+ return; // already present
1280
+ } catch { /* missing → try to fetch it */ }
1281
+ try {
1282
+ if (!fs.existsSync(submoduleRepoPath) || !fs.existsSync(baseSubmoduleRepoPath)) return;
1283
+ // Fetch the exact object from the base repo's submodule by absolute path. `file://`
1284
+ // fetch of an explicit sha requires uploadpack.allowAnySHA1InWant on some gits;
1285
+ // fetching the base submodule's HEAD/all refs is the portable way to bring the
1286
+ // reachable object in, so fetch all branches and tags from the local path.
1287
+ execFileSync(GIT, ['-c', 'protocol.file.allow=always', 'fetch', '-q', baseSubmoduleRepoPath, '+refs/heads/*:refs/adhdev-refine-base/*'], {
1288
+ cwd: submoduleRepoPath,
1289
+ stdio: ['ignore', 'ignore', 'pipe'],
1290
+ });
1291
+ } catch { /* best-effort; ancestry check will simply decline if still missing */ }
1292
+ }
1293
+
1294
+ /**
1295
+ * STEP 1 of auto-converging diverged oss-style submodule gitlinks: rebase the
1296
+ * branch-side submodule commit(s) onto the base-side submodule commit INSIDE the
1297
+ * worktree's submodule checkout (detached HEAD), so the base-side commit becomes a
1298
+ * strict ancestor of the rebased tip. Returns the rebased commit per path so the
1299
+ * caller's root rebase can resolve the gitlink conflict to it (STEP 2, see
1300
+ * {@link rootRebaseResolvingGitlinks}). This automates the documented manual
1301
+ * strict-fast-forward bypass and keeps the landed submodule history linear rather
1302
+ * than masking a divergence.
1303
+ *
1304
+ * Fail-safe by construction: it only touches gitlinks that are a genuine sibling
1305
+ * divergence (both commits local, neither an ancestor of the other, shared merge
1306
+ * base). A submodule rebase content conflict aborts, restores the branch-side
1307
+ * checkout, and returns `converged:false` (caller keeps defer→blocked_review). It
1308
+ * never commits the root and NEVER pushes — remote publish is the merge/
1309
+ * reachability stage's job; this stage is local reconciliation only.
1310
+ *
1311
+ * @param worktreeRoot the branch worktree root (its `<path>` submodule checkout is rebased)
1312
+ * @param baseRepoRoot the source/base repo root (reads the base-side gitlink commit)
1313
+ * @param baseHead the fetched base head (root ref) — source of base-side gitlink commits
1314
+ * @param branchHead the worktree branch head (root ref) — source of branch-side gitlink commits
1315
+ */
1316
+ export function convergeDivergedSubmoduleGitlinks(
1317
+ worktreeRoot: string,
1318
+ baseRepoRoot: string,
1319
+ baseHead: string,
1320
+ branchHead: string,
1321
+ ): SubmoduleGitlinkConvergeResult {
1322
+ const changed = readChangedGitlinkPaths(worktreeRoot, baseHead, branchHead);
1323
+ if (changed.length === 0) {
1324
+ return { converged: false, reason: 'no_changed_gitlinks', resolutions: [], gitlinks: [] };
1325
+ }
1326
+
1327
+ const gitlinks: SubmoduleGitlinkConvergeResult['gitlinks'] = [];
1328
+ const resolutions: SubmoduleGitlinkConvergeResult['resolutions'] = [];
1329
+ let sawDiverged = false;
1330
+
1331
+ for (const path of changed) {
1332
+ // Base-side commit comes from the base repo's tree; branch-side from the worktree's.
1333
+ const baseCommit = readTreeObject(baseRepoRoot, baseHead, path);
1334
+ const branchCommit = readTreeObject(worktreeRoot, branchHead, path);
1335
+ const submoduleRepoPath = pathResolve(worktreeRoot, path);
1336
+
1337
+ // The base-side submodule commit is recorded by the base workspace but may not
1338
+ // yet exist in the worktree's submodule object store (it was committed locally
1339
+ // in base/<path> and not necessarily fetched here). Make it available via a
1340
+ // best-effort local fetch from the base repo's own submodule checkout so the
1341
+ // ancestry check and rebase can see it. Without this, a genuinely-convergeable
1342
+ // divergence would look "not local" and fall through to blocked_review.
1343
+ if (baseCommit) {
1344
+ ensureSubmoduleCommitLocal(submoduleRepoPath, pathResolve(baseRepoRoot, path), baseCommit);
1345
+ }
1346
+
1347
+ if (!baseCommit || !branchCommit || !isSubmoduleDivergedSibling(submoduleRepoPath, baseCommit, branchCommit)) {
1348
+ gitlinks.push({ path, baseCommit, branchCommit, action: 'skipped_not_diverged' });
1349
+ continue;
1350
+ }
1351
+ sawDiverged = true;
1352
+
1353
+ // Rebase the branch-side submodule commit(s) onto the base-side commit in a
1354
+ // DETACHED HEAD (never move a submodule branch ref). A conflict aborts and
1355
+ // restores the submodule checkout to the branch-side commit.
1356
+ let rebasedCommit: string | undefined;
1357
+ try {
1358
+ execFileSync(GIT, ['checkout', '-q', '--detach', branchCommit], { cwd: submoduleRepoPath, stdio: ['ignore', 'ignore', 'pipe'] });
1359
+ execFileSync(GIT, ['rebase', baseCommit], { cwd: submoduleRepoPath, stdio: ['ignore', 'pipe', 'pipe'] });
1360
+ rebasedCommit = execFileSync(GIT, ['rev-parse', 'HEAD'], { cwd: submoduleRepoPath, encoding: 'utf8' }).trim();
1361
+ } catch {
1362
+ try { execFileSync(GIT, ['rebase', '--abort'], { cwd: submoduleRepoPath, stdio: 'ignore' }); } catch { /* ignore */ }
1363
+ try { execFileSync(GIT, ['checkout', '-q', '--detach', branchCommit], { cwd: submoduleRepoPath, stdio: 'ignore' }); } catch { /* ignore */ }
1364
+ gitlinks.push({ path, baseCommit, branchCommit, action: 'rebase_conflict' });
1365
+ // Real submodule content conflict → do NOT converge; caller keeps blocked_review.
1366
+ return { converged: false, reason: 'rebase_conflict', resolutions: [], gitlinks };
1367
+ }
1368
+
1369
+ gitlinks.push({ path, baseCommit, branchCommit, rebasedCommit, action: 'rebased' });
1370
+ resolutions.push({ path, baseCommit, branchCommit, rebasedCommit: rebasedCommit! });
1371
+ }
1372
+
1373
+ if (!sawDiverged) {
1374
+ return { converged: false, reason: 'not_diverged', resolutions: [], gitlinks };
1375
+ }
1376
+ return { converged: resolutions.length > 0, resolutions, gitlinks };
1377
+ }
1378
+
1379
+ export type RootRebaseGitlinkResolveResult = {
1380
+ /** True when the root rebase completed (with gitlink conflicts resolved to the converged commits). */
1381
+ ok: boolean;
1382
+ /** New root HEAD after the rebase (only meaningful when ok). */
1383
+ branchHead?: string;
1384
+ /**
1385
+ * Set when the rebase was aborted (fail-safe). One of:
1386
+ * non_gitlink_conflict — a conflict on a non-submodule path (genuine content conflict)
1387
+ * unexpected_gitlink — a gitlink conflicted that we have no converged commit for
1388
+ * rebase_error — the rebase failed for a non-conflict reason
1389
+ */
1390
+ reason?: string;
1391
+ /** The paths that conflicted at the point of abort (for diagnostics). */
1392
+ conflictPaths?: string[];
1393
+ };
1394
+
1395
+ /**
1396
+ * STEP 2 of auto-converging diverged submodule gitlinks: rebase the worktree root
1397
+ * branch onto `baseHead`, resolving each submodule-gitlink conflict to the
1398
+ * pre-converged commit from {@link convergeDivergedSubmoduleGitlinks}. git's
1399
+ * recursive merge refuses to auto-merge a diverged gitlink ("Recursive merging
1400
+ * with submodules currently only supports trivial cases"), so we drive the rebase
1401
+ * ourselves: on each stop, if the ONLY unmerged paths are gitlinks we have a
1402
+ * converged commit for, we stage those to the converged commit and `--continue`.
1403
+ * Any non-gitlink conflict (or a gitlink with no converged commit) aborts the
1404
+ * rebase and returns `ok:false` → caller keeps the defer→blocked_review path.
1405
+ *
1406
+ * On success the base-side gitlink is a strict ancestor of the resolved branch-side
1407
+ * commit, so the downstream patch-equivalence gate treats it as a trivial
1408
+ * fast-forward and passes.
1409
+ */
1410
+ export function rootRebaseResolvingGitlinks(
1411
+ worktreeRoot: string,
1412
+ baseHead: string,
1413
+ resolutions: Array<{ path: string; rebasedCommit: string }>,
1414
+ ): RootRebaseGitlinkResolveResult {
1415
+ const resolveByPath = new Map(resolutions.map(r => [r.path, r.rebasedCommit]));
1416
+
1417
+ const runRebase = (args: string[]): { ok: boolean } => {
1418
+ try {
1419
+ execFileSync(GIT, args, {
1420
+ cwd: worktreeRoot,
1421
+ stdio: ['ignore', 'pipe', 'pipe'],
1422
+ // A rebase editor prompt would hang; keep it non-interactive.
1423
+ env: { ...process.env, GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' },
1424
+ });
1425
+ return { ok: true };
1426
+ } catch {
1427
+ return { ok: false };
1428
+ }
1429
+ };
1430
+
1431
+ const unmergedPaths = (): string[] => {
1432
+ try {
1433
+ return execFileSync(GIT, ['diff', '--name-only', '--diff-filter=U'], { cwd: worktreeRoot, encoding: 'utf8' })
1434
+ .split('\n').map(s => s.trim()).filter(Boolean);
1435
+ } catch {
1436
+ return [];
1437
+ }
1438
+ };
1439
+
1440
+ const abort = (reason: string, conflictPaths?: string[]): RootRebaseGitlinkResolveResult => {
1441
+ try { execFileSync(GIT, ['rebase', '--abort'], { cwd: worktreeRoot, stdio: 'ignore' }); } catch { /* ignore */ }
1442
+ return { ok: false, reason, conflictPaths };
1443
+ };
1444
+
1445
+ let progress = runRebase(['rebase', baseHead]);
1446
+ let guard = 0;
1447
+ while (!progress.ok) {
1448
+ if (guard++ > 100) return abort('rebase_error');
1449
+ const conflicts = unmergedPaths();
1450
+ if (conflicts.length === 0) {
1451
+ // Failed but no recorded conflicts — a non-conflict rebase error.
1452
+ return abort('rebase_error');
1453
+ }
1454
+ // Every conflicting path must be a gitlink we have a converged commit for.
1455
+ const unresolvable = conflicts.filter(p => !resolveByPath.has(p));
1456
+ if (unresolvable.length > 0) {
1457
+ // Distinguish a genuine (non-gitlink) content conflict from a gitlink we
1458
+ // simply have no converged commit for. `ls-files --stage` reports mode
1459
+ // 160000 for a gitlink at any conflict stage.
1460
+ const unresolvableGitlink = unresolvable.some(p => {
1461
+ try {
1462
+ const staged = execFileSync(GIT, ['ls-files', '--stage', '--', p], { cwd: worktreeRoot, encoding: 'utf8' });
1463
+ return /^160000\s/m.test(staged);
1464
+ } catch {
1465
+ return false;
1466
+ }
1467
+ });
1468
+ const allGitlink = unresolvable.every(p => {
1469
+ try {
1470
+ const staged = execFileSync(GIT, ['ls-files', '--stage', '--', p], { cwd: worktreeRoot, encoding: 'utf8' });
1471
+ return /^160000\s/m.test(staged);
1472
+ } catch {
1473
+ return false;
1474
+ }
1475
+ });
1476
+ // A non-gitlink conflict is the fail-safe case that must clearly signal a
1477
+ // genuine content conflict; a gitlink-only miss is the (rarer) case where a
1478
+ // gitlink conflicted that STEP 1 did not converge.
1479
+ return abort(allGitlink && unresolvableGitlink ? 'unexpected_gitlink' : 'non_gitlink_conflict', conflicts);
1480
+ }
1481
+ // Stage every conflicting gitlink to its converged commit, then continue.
1482
+ for (const p of conflicts) {
1483
+ const commit = resolveByPath.get(p)!;
1484
+ try {
1485
+ execFileSync(GIT, ['checkout', '-q', '--detach', commit], { cwd: pathResolve(worktreeRoot, p), stdio: 'ignore' });
1486
+ } catch { /* the checkout is best-effort; the `add` below stamps the index either way */ }
1487
+ try {
1488
+ execFileSync(GIT, ['add', p], { cwd: worktreeRoot, stdio: 'ignore' });
1489
+ } catch {
1490
+ return abort('rebase_error', conflicts);
1491
+ }
1492
+ }
1493
+ progress = runRebase(['rebase', '--continue']);
1494
+ }
1495
+
1496
+ let branchHead: string | undefined;
1497
+ try {
1498
+ branchHead = execFileSync(GIT, ['rev-parse', 'HEAD'], { cwd: worktreeRoot, encoding: 'utf8' }).trim();
1499
+ } catch { /* leave undefined */ }
1500
+ return { ok: true, branchHead };
1501
+ }
1502
+
1203
1503
  /**
1204
1504
  * Decide whether a merge-tree submodule conflict between base and branch is a
1205
1505
  * trivial gitlink fast-forward (and nothing else).