@adhdev/daemon-core 1.0.18-rc.1 → 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.
- package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
- package/dist/cli-adapters/pty-transport.d.ts +2 -1
- package/dist/commands/process-lifecycle.d.ts +43 -0
- package/dist/commands/upgrade-helper.d.ts +7 -0
- package/dist/commands/windows-atomic-upgrade.d.ts +63 -0
- package/dist/index.js +6513 -4536
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6539 -4556
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +76 -0
- package/dist/mesh/mesh-active-work.d.ts +1 -1
- package/dist/mesh/mesh-disk-retention.d.ts +105 -0
- package/dist/mesh/mesh-ledger.d.ts +28 -1
- package/dist/mesh/mesh-node-identity.d.ts +23 -0
- package/dist/mesh/mesh-refine-gates.d.ts +89 -0
- package/dist/mesh/mesh-runtime-store.d.ts +11 -0
- package/dist/mesh/mesh-work-queue.d.ts +24 -1
- package/dist/providers/cli-provider-instance-types.d.ts +4 -0
- package/dist/providers/cli-provider-instance.d.ts +2 -0
- package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
- package/dist/providers/types/interactive-prompt.d.ts +18 -0
- package/package.json +3 -3
- package/src/boot/daemon-lifecycle.ts +10 -0
- package/src/cli-adapters/cli-state-engine.ts +204 -3
- package/src/cli-adapters/provider-cli-adapter.ts +39 -5
- package/src/cli-adapters/pty-transport.d.ts +2 -1
- package/src/cli-adapters/pty-transport.ts +1 -1
- package/src/cli-adapters/session-host-transport.ts +8 -3
- package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
- package/src/commands/high-family/mesh-events.ts +13 -2
- package/src/commands/med-family/mesh-queue.ts +74 -1
- package/src/commands/process-lifecycle.ts +248 -0
- package/src/commands/router-refine.ts +71 -4
- package/src/commands/router.ts +8 -0
- package/src/commands/upgrade-helper.ts +146 -79
- package/src/commands/windows-atomic-upgrade.ts +631 -0
- package/src/config/mesh-json-config.ts +8 -0
- package/src/mesh/coordinator-prompt.ts +258 -11
- package/src/mesh/mesh-active-work.ts +11 -1
- package/src/mesh/mesh-disk-retention.ts +370 -0
- package/src/mesh/mesh-event-classify.ts +19 -0
- package/src/mesh/mesh-event-forwarding.ts +46 -4
- package/src/mesh/mesh-events-utils.ts +42 -0
- package/src/mesh/mesh-ledger.ts +77 -0
- package/src/mesh/mesh-node-identity.ts +49 -0
- package/src/mesh/mesh-queue-assignment.ts +144 -2
- package/src/mesh/mesh-reconcile-loop.ts +55 -1
- package/src/mesh/mesh-refine-gates.ts +300 -0
- package/src/mesh/mesh-runtime-store.ts +21 -0
- package/src/mesh/mesh-work-queue.ts +85 -2
- package/src/providers/cli-provider-instance-types.ts +53 -0
- package/src/providers/cli-provider-instance.ts +193 -2
- package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
- package/src/providers/types/interactive-prompt.ts +77 -0
- package/src/session-host/managed-host.ts +34 -0
|
@@ -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
|
}
|
|
@@ -6,7 +6,7 @@ import { getMesh } from '../config/mesh-config.js';
|
|
|
6
6
|
import { detectCLI } from '../detection/cli-detector.js';
|
|
7
7
|
import { LOG } from '../logging/logger.js';
|
|
8
8
|
import { appendLedgerEntry } from './mesh-ledger.js';
|
|
9
|
-
import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly, taskDependenciesSatisfied, meshTaskNotBeforeReady, meshTaskPriorityRank } from './mesh-work-queue.js';
|
|
9
|
+
import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly, taskDependenciesSatisfied, meshTaskNotBeforeReady, meshTaskPriorityRank, requeueTask } from './mesh-work-queue.js';
|
|
10
10
|
import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
|
|
11
11
|
import { fastForwardMeshNode } from './mesh-fast-forward.js';
|
|
12
12
|
import { createSessionDelivery, updateSessionDeliveryStatus } from './mesh-delivery-policy.js';
|
|
@@ -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';
|
|
@@ -931,6 +931,105 @@ function isTargetNodeTransientlyUnresolved(mesh: any, task: MeshWorkQueueEntry):
|
|
|
931
931
|
return isWithinCloneBootstrapGrace(targetNodeId);
|
|
932
932
|
}
|
|
933
933
|
|
|
934
|
+
// ---------------------------------------------------------------------------
|
|
935
|
+
// DEAD-TARGET-SELFHEAL: unpin a queue task hard-pinned to a session/node that has
|
|
936
|
+
// died (absent from the live mesh) so a live idle session can claim it, instead of
|
|
937
|
+
// leaving it stranded 'pending' forever behind the target_session_constraint skip.
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
|
|
940
|
+
// Conservative age gate before a pinned-but-dead target is unpinned. A target that is
|
|
941
|
+
// merely briefly unassigned or momentarily reconnecting must not be reclaimed on the
|
|
942
|
+
// tick it drops out of view; we require the task to have been idle (no updatedAt bump)
|
|
943
|
+
// for at least this window first. Sized to comfortably outlast a transient P2P blip /
|
|
944
|
+
// reconnect while staying well inside the reclaim cadence of the rest of the file
|
|
945
|
+
// (AUTO_LAUNCH_AWAIT_CLAIM_MS is 90s; the stranded-reclaim watchdog fires on similar
|
|
946
|
+
// scales), so a real reconnect wins the race and the self-heal only fires on a target
|
|
947
|
+
// that is genuinely gone.
|
|
948
|
+
const DEAD_TARGET_GRACE_MS = 60_000;
|
|
949
|
+
|
|
950
|
+
interface DeadTargetVerdict {
|
|
951
|
+
/** The pinned target is confirmed dead and past the grace window → safe to unpin. */
|
|
952
|
+
dead: boolean;
|
|
953
|
+
/** True when the target NODE itself is absent from the live mesh (clear targetNodeId too). */
|
|
954
|
+
nodeDead: boolean;
|
|
955
|
+
/** Short reason string for the ledger/requeue. */
|
|
956
|
+
reason: string;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Decide whether a task's hard target pin (targetSessionId and/or targetNodeId) points at
|
|
961
|
+
* something that has DIED — i.e. is absent from the live mesh snapshot — and has been so
|
|
962
|
+
* long enough (DEAD_TARGET_GRACE_MS since the task's last update) that unpinning is safe.
|
|
963
|
+
*
|
|
964
|
+
* Two definitive death signals, deliberately conservative to never race a reconnecting node:
|
|
965
|
+
*
|
|
966
|
+
* (1) NODE dead — the task pins a targetNodeId that matches NO node in the live mesh
|
|
967
|
+
* (the same `meshNodeIdMatches`-over-mesh.nodes signal the targetPinUnmatched relabel
|
|
968
|
+
* uses at the empty-candidate site). A pinned session on an absent node is unreachable
|
|
969
|
+
* regardless, so the session pin is dead too. `nodeDead` ⇒ clear targetNodeId as well.
|
|
970
|
+
* Excluded: a target that is only TRANSIENTLY unresolved (a freshly-cloned worktree
|
|
971
|
+
* still propagating / bootstrapping) — isTargetNodeTransientlyUnresolved gates it out.
|
|
972
|
+
*
|
|
973
|
+
* (2) SESSION dead on a LIVE LOCAL node — the target node IS present and is THIS daemon's
|
|
974
|
+
* node, but the pinned session is absent from the local instance manager
|
|
975
|
+
* (resolveSessionBusyVerdict === 'UNKNOWN'). Local session visibility is complete, so
|
|
976
|
+
* absence here is genuine death, not a busy/generating flip. We KEEP targetNodeId (only
|
|
977
|
+
* the session died; the node is healthy and can host a replacement claim).
|
|
978
|
+
*
|
|
979
|
+
* A live REMOTE node whose session is not in our idle view is NOT treated as dead: absence
|
|
980
|
+
* from the remote-idle mirror is explicitly UNKNOWN liveness (the session may be busy or its
|
|
981
|
+
* agent:ready pull merely lost), so unpinning it could race healthy in-flight work. Returns
|
|
982
|
+
* dead=false in that case, leaving the existing skip in place.
|
|
983
|
+
*/
|
|
984
|
+
function resolveDeadTargetVerdict(components: DaemonComponents, meshId: string, mesh: any, task: MeshWorkQueueEntry): DeadTargetVerdict {
|
|
985
|
+
const NOT_DEAD: DeadTargetVerdict = { dead: false, nodeDead: false, reason: '' };
|
|
986
|
+
const targetSessionId = readNonEmptyString(task.targetSessionId);
|
|
987
|
+
const targetNodeId = readNonEmptyString(task.targetNodeId);
|
|
988
|
+
if (!targetSessionId && !targetNodeId) return NOT_DEAD;
|
|
989
|
+
|
|
990
|
+
// Age gate: never reclaim a pin younger than the grace window (guards against a target
|
|
991
|
+
// that has only just dropped out of view for a momentary reconnect).
|
|
992
|
+
const lastUpdateMs = Date.parse(task.updatedAt || task.createdAt || '');
|
|
993
|
+
if (Number.isFinite(lastUpdateMs) && Date.now() - lastUpdateMs < DEAD_TARGET_GRACE_MS) return NOT_DEAD;
|
|
994
|
+
|
|
995
|
+
const nodes: any[] = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
|
|
996
|
+
|
|
997
|
+
// (1) NODE-dead — a pinned node absent from the live mesh, and NOT merely transiently
|
|
998
|
+
// unresolved (a propagating/bootstrapping clone). This is a permanent routing miss.
|
|
999
|
+
if (targetNodeId) {
|
|
1000
|
+
const nodePresent = nodes.some(n => meshNodeIdMatches(n, targetNodeId));
|
|
1001
|
+
if (!nodePresent) {
|
|
1002
|
+
if (isTargetNodeTransientlyUnresolved(mesh, task)) return NOT_DEAD;
|
|
1003
|
+
return { dead: true, nodeDead: true, reason: 'dead_target_node_absent' };
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// (2) SESSION-dead on a LIVE LOCAL node. Only meaningful when a session is pinned.
|
|
1008
|
+
if (targetSessionId) {
|
|
1009
|
+
// Resolve the pinned target's node (if any) to decide whether we can trust local
|
|
1010
|
+
// absence. Without a targetNodeId, fall back to whichever live node hosts the session
|
|
1011
|
+
// is unknowable here; treat that as a LOCAL check only (a session id we cannot see
|
|
1012
|
+
// locally on a node we cannot resolve remotely stays UNKNOWN → not dead).
|
|
1013
|
+
const node = targetNodeId
|
|
1014
|
+
? nodes.find(n => meshNodeIdMatches(n, targetNodeId))
|
|
1015
|
+
: undefined;
|
|
1016
|
+
// A pinned session on a REMOTE live node: absence from our view is UNKNOWN, not death.
|
|
1017
|
+
// Only a LOCAL node (or no node pin at all — same-daemon assumption) lets us conclude
|
|
1018
|
+
// death from local instance-manager absence.
|
|
1019
|
+
const nodeIsLocal = node ? isLocalAutoLaunchNode(node) : true;
|
|
1020
|
+
if (nodeIsLocal) {
|
|
1021
|
+
const verdict = resolveSessionBusyVerdict(components, targetSessionId);
|
|
1022
|
+
if (verdict === 'UNKNOWN') {
|
|
1023
|
+
// Session absent from the complete local session view → genuinely gone.
|
|
1024
|
+
return { dead: true, nodeDead: false, reason: 'dead_target_session_absent' };
|
|
1025
|
+
}
|
|
1026
|
+
// GENERATING / IDLE_CONFIRMED → the session is alive (possibly busy). Never disturb.
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
return NOT_DEAD;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
934
1033
|
/**
|
|
935
1034
|
* FALSE-BLOCKER-CLONE-QUEUE (stale-event clear): once a task whose actionable blocker we
|
|
936
1035
|
* previously paged either gets claimed or transitions to a self-resolving state, re-arm the
|
|
@@ -1888,6 +1987,11 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
1888
1987
|
}
|
|
1889
1988
|
if (!pending.length) return false;
|
|
1890
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
|
+
|
|
1891
1995
|
// Write cap + read-only cap resolved through the shared helpers from the
|
|
1892
1996
|
// MACHINE-LOCAL stored mesh policy (no repo-file overlay). These are the same
|
|
1893
1997
|
// resolvers the observability projection uses, so the enforced and exposed
|
|
@@ -1929,6 +2033,32 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
1929
2033
|
continue;
|
|
1930
2034
|
}
|
|
1931
2035
|
if (task.targetSessionId) {
|
|
2036
|
+
// DEAD-TARGET-SELFHEAL: before the unconditional target_session_constraint skip,
|
|
2037
|
+
// check whether the pinned session/node has DIED (absent from the live mesh). A
|
|
2038
|
+
// hard-pinned task whose target is gone can NEVER re-enter 'assigned' (the claim
|
|
2039
|
+
// gate refuses every non-matching session) and this skip fires forever with no
|
|
2040
|
+
// liveness check — the triple-walled stranded-pending defect. If the pin is
|
|
2041
|
+
// confirmed dead past the grace window, requeue it (clearing the dead session
|
|
2042
|
+
// pin, and the node pin too when the NODE itself is gone) so a live idle session
|
|
2043
|
+
// can claim it. requeueTask counts toward maxTaskRetries → bounded self-heal that
|
|
2044
|
+
// auto-fails past the cap (the desired terminal state, unblocking dependents).
|
|
2045
|
+
const deadTarget = resolveDeadTargetVerdict(components, meshId, mesh, task);
|
|
2046
|
+
if (deadTarget.dead) {
|
|
2047
|
+
const requeued = requeueTask(meshId, task.id, {
|
|
2048
|
+
reason: deadTarget.reason,
|
|
2049
|
+
clearTargetSession: true,
|
|
2050
|
+
// Keep the node pin if only the SESSION died on a still-live node; clear it
|
|
2051
|
+
// when the NODE itself is absent (nothing to pin to).
|
|
2052
|
+
clearTargetNode: deadTarget.nodeDead,
|
|
2053
|
+
});
|
|
2054
|
+
if (requeued) {
|
|
2055
|
+
LOG.warn('MeshQueue', `DEAD-TARGET-SELFHEAL: task ${task.id} (mesh ${meshId}) was pinned to a dead target (${deadTarget.reason}); requeued${deadTarget.nodeDead ? ' and unpinned node' : ''} (requeueCount=${requeued.requeueCount ?? '?'}, status=${requeued.status}).`);
|
|
2056
|
+
}
|
|
2057
|
+
// Keep the skip for THIS tick (the requeue already flipped the row to
|
|
2058
|
+
// pending/failed); a later tick assigns/launches the now-unpinned task.
|
|
2059
|
+
markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'target_session_dead_requeued' });
|
|
2060
|
+
continue;
|
|
2061
|
+
}
|
|
1932
2062
|
markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'target_session_constraint' });
|
|
1933
2063
|
continue;
|
|
1934
2064
|
}
|
|
@@ -2085,6 +2215,18 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
2085
2215
|
markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_not_launch_ready', nodeId });
|
|
2086
2216
|
continue;
|
|
2087
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
|
+
}
|
|
2088
2230
|
const launchTarget = resolveAutoLaunchTarget(components, node);
|
|
2089
2231
|
if (launchTarget.mode === 'skip') {
|
|
2090
2232
|
// Remote node we can't reach (no transport / no coordinator daemonId).
|
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
resolveReconcileIntervalMs,
|
|
77
77
|
} from './mesh-reconcile-config.js';
|
|
78
78
|
import { pullRemoteNodeQueues } from './mesh-remote-event-pull.js';
|
|
79
|
+
import { runDiskRetentionSweep, detectAndSignalOrphanWorktrees } from './mesh-disk-retention.js';
|
|
79
80
|
import {
|
|
80
81
|
reconcileUnterminatedDirectDispatches,
|
|
81
82
|
autoPruneStaleDirectDispatches,
|
|
@@ -126,6 +127,16 @@ interface LiveCoordinator {
|
|
|
126
127
|
// "restart does not clear it" symptom the operator needs visibility into).
|
|
127
128
|
const coordinatorModalParkState = new Map<string, boolean>();
|
|
128
129
|
|
|
130
|
+
// Disk/worktree retention throttle. The reconcile tick runs every ~4s, but the
|
|
131
|
+
// retention sweep (fs walks + git worktree list) is far too heavy for that cadence
|
|
132
|
+
// and its artifacts age in days, so it runs at most once per hour. `undefined`
|
|
133
|
+
// means "never run yet" → runs on the first tick after daemon start so a long-lived
|
|
134
|
+
// backlog is reclaimed promptly rather than an hour later. Per-process; a restart
|
|
135
|
+
// re-runs it immediately, which is the desired behavior (a restart is exactly when
|
|
136
|
+
// stale artifacts from the previous run should be swept).
|
|
137
|
+
const DISK_RETENTION_INTERVAL_MS = 60 * 60 * 1000; // 1h
|
|
138
|
+
let lastDiskRetentionRunAt: number | undefined;
|
|
139
|
+
|
|
129
140
|
// Find live CLI coordinator instances on THIS daemon, keyed by mesh.
|
|
130
141
|
function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
|
|
131
142
|
const out: LiveCoordinator[] = [];
|
|
@@ -743,7 +754,12 @@ function assignedRowLiveStatusIsAwaitingApproval(
|
|
|
743
754
|
): boolean {
|
|
744
755
|
if (!nodeId || !sessionId) return false;
|
|
745
756
|
try {
|
|
746
|
-
|
|
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';
|
|
747
763
|
} catch {
|
|
748
764
|
return false;
|
|
749
765
|
}
|
|
@@ -1354,6 +1370,44 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
1354
1370
|
}
|
|
1355
1371
|
}
|
|
1356
1372
|
|
|
1373
|
+
// ── PHASE 6: disk / worktree retention (hourly, mission 86def38d) ──────────
|
|
1374
|
+
// Legacy on-disk artifacts under ~/.adhdev accumulated with NO lifetime and grew
|
|
1375
|
+
// the data volume until a refine bootstrap failed at 98% disk. This throttled pass
|
|
1376
|
+
// (≤1×/hour — the artifacts age in days and the fs/git walk is too heavy for the 4s
|
|
1377
|
+
// tick) reclaims them:
|
|
1378
|
+
// • JSONL ledger files older than 30d (legacy after the SQLite ledger)
|
|
1379
|
+
// • terminated session-host runtime files older than 14d (live never touched)
|
|
1380
|
+
// • mesh-runtime.db.bak-* backups older than 7d
|
|
1381
|
+
// • orphan worktree DETECTION → cleanup_candidate ledger signal (NEVER deleted).
|
|
1382
|
+
// Runs BEFORE PHASE 2's early return so it fires whether or not a live CLI
|
|
1383
|
+
// coordinator exists on this daemon. Isolated in try/catch so it can never kill the
|
|
1384
|
+
// tick. All file deletions are defensive (age + live-runtime guards in the pure
|
|
1385
|
+
// selectors); worktree cleanup stays manual/coordinator-driven.
|
|
1386
|
+
{
|
|
1387
|
+
const nowMs = Date.now();
|
|
1388
|
+
const due = lastDiskRetentionRunAt === undefined
|
|
1389
|
+
|| (nowMs - lastDiskRetentionRunAt) >= DISK_RETENTION_INTERVAL_MS;
|
|
1390
|
+
if (due) {
|
|
1391
|
+
lastDiskRetentionRunAt = nowMs;
|
|
1392
|
+
try {
|
|
1393
|
+
runDiskRetentionSweep(nowMs);
|
|
1394
|
+
} catch (e: any) {
|
|
1395
|
+
LOG.warn('MeshReconcile', `Disk retention sweep failed: ${e?.message || e}`);
|
|
1396
|
+
}
|
|
1397
|
+
// Orphan-worktree detection is per-mesh (needs the mesh's base repo + node set)
|
|
1398
|
+
// and only for meshes this daemon hosts (its local base checkout is the git anchor).
|
|
1399
|
+
for (const mesh of listMeshes()) {
|
|
1400
|
+
const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
|
|
1401
|
+
if (!daemonHostsMesh(mesh, selfIds)) continue;
|
|
1402
|
+
try {
|
|
1403
|
+
await detectAndSignalOrphanWorktrees(mesh, nowMs);
|
|
1404
|
+
} catch (e: any) {
|
|
1405
|
+
LOG.warn('MeshReconcile', `Orphan worktree detection failed for mesh ${mesh.id}: ${e?.message || e}`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1357
1411
|
// ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
|
|
1358
1412
|
const coordinators = findLiveCoordinators(components);
|
|
1359
1413
|
if (coordinators.length === 0) {
|
|
@@ -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).
|