@adhdev/daemon-core 0.9.82-rc.261 → 0.9.82-rc.263
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.d.ts +37 -0
- package/dist/commands/router.d.ts +116 -0
- package/dist/git/git-status.d.ts +7 -0
- package/dist/git/git-types.d.ts +19 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +971 -69
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +967 -69
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-active-work.d.ts +18 -0
- package/dist/mesh/mesh-fast-forward.d.ts +41 -1
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-runtime-store.d.ts +6 -0
- package/dist/mesh/mesh-work-queue.d.ts +6 -0
- package/package.json +1 -1
- package/src/build-info.ts +73 -0
- package/src/commands/router.ts +805 -9
- package/src/git/git-status.ts +73 -1
- package/src/git/git-types.ts +20 -0
- package/src/index.ts +5 -2
- package/src/mesh/mesh-active-work.ts +31 -0
- package/src/mesh/mesh-fast-forward.ts +418 -17
- package/src/mesh/mesh-ledger.ts +1 -0
- package/src/mesh/mesh-runtime-store.ts +19 -0
- package/src/mesh/mesh-work-queue.ts +13 -0
package/src/commands/router.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
type WorktreeBootstrapState,
|
|
65
65
|
} from '../mesh/worktree-bootstrap-config.js';
|
|
66
66
|
import { buildMachineInfo, buildStatusSnapshot } from '../status/snapshot.js';
|
|
67
|
+
import { getDaemonBuildInfo } from '../build-info.js';
|
|
67
68
|
import { getSessionCompletionMarker } from '../status/snapshot.js';
|
|
68
69
|
import { execNpmCommandSync, resolveCurrentGlobalInstallSurface, spawnDetachedDaemonUpgradeHelper } from './upgrade-helper.js';
|
|
69
70
|
import { getMeshQueueRevision } from '../mesh/mesh-work-queue.js';
|
|
@@ -1397,6 +1398,31 @@ type MeshRefinePatchEquivalenceSummary = {
|
|
|
1397
1398
|
stdout?: string;
|
|
1398
1399
|
stderr?: string;
|
|
1399
1400
|
actionableHint?: MeshRefineSubmoduleConflictHint;
|
|
1401
|
+
/**
|
|
1402
|
+
* Set when a `merge-tree` submodule conflict was reclassified as a trivial
|
|
1403
|
+
* gitlink fast-forward and the gate passed via a synthesized merge tree.
|
|
1404
|
+
*/
|
|
1405
|
+
gitlinkTrivialFastForward?: {
|
|
1406
|
+
resolved: boolean;
|
|
1407
|
+
gitlinks: Array<{ path: string; baseCommit?: string; branchCommit?: string; fastForward: boolean }>;
|
|
1408
|
+
reason?: string;
|
|
1409
|
+
};
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
type MeshRefineEffectiveDiffSummary = {
|
|
1413
|
+
status: MeshRefineStageStatus;
|
|
1414
|
+
/** True when there is at least one root-tree change between base and branch (incl. gitlink bumps). */
|
|
1415
|
+
hasEffectiveDiff: boolean;
|
|
1416
|
+
baseHead: string;
|
|
1417
|
+
branchHead: string;
|
|
1418
|
+
/** Root-level paths that differ between base and branch (capped). */
|
|
1419
|
+
changedPaths?: string[];
|
|
1420
|
+
/** Submodule paths with uncommitted/divergent commits but NO committed gitlink bump in the root tree. */
|
|
1421
|
+
submoduleHints?: Array<{ path: string; reason: string }>;
|
|
1422
|
+
durationMs: number;
|
|
1423
|
+
error?: string;
|
|
1424
|
+
stdout?: string;
|
|
1425
|
+
stderr?: string;
|
|
1400
1426
|
};
|
|
1401
1427
|
|
|
1402
1428
|
type MeshRefineSubmoduleConflictHint = {
|
|
@@ -1496,6 +1522,44 @@ type MeshRefineJobHandle = {
|
|
|
1496
1522
|
|
|
1497
1523
|
type MeshRefineTerminalJob = MeshRefineJobHandle & { result?: Record<string, unknown> };
|
|
1498
1524
|
|
|
1525
|
+
type MeshRefineBatchJobStatus = 'accepted' | 'completed' | 'failed';
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* Async handle returned by the batch Refinery the instant a convergence run is
|
|
1529
|
+
* accepted. Mirrors {@link MeshRefineJobHandle} (async:true / status:'accepted' +
|
|
1530
|
+
* terminal pending-event + ledger delivery) but scopes a whole batch of sibling
|
|
1531
|
+
* nodes rather than a single node. The synthetic `batchLabel` is used as the
|
|
1532
|
+
* `nodeLabel` for the shared refine event/message renderer.
|
|
1533
|
+
*/
|
|
1534
|
+
type MeshRefineBatchJobHandle = {
|
|
1535
|
+
success: true;
|
|
1536
|
+
async: true;
|
|
1537
|
+
batch: true;
|
|
1538
|
+
status: MeshRefineBatchJobStatus;
|
|
1539
|
+
jobId: string;
|
|
1540
|
+
interactionId: string;
|
|
1541
|
+
meshId: string;
|
|
1542
|
+
batchLabel: string;
|
|
1543
|
+
nodeIds: string[];
|
|
1544
|
+
nodeCount: number;
|
|
1545
|
+
order: string[];
|
|
1546
|
+
startedAt: string;
|
|
1547
|
+
completedAt?: string;
|
|
1548
|
+
duplicate?: boolean;
|
|
1549
|
+
targetCoordinatorDaemonId?: string;
|
|
1550
|
+
eventDelivery: {
|
|
1551
|
+
pendingEvents: true;
|
|
1552
|
+
ledger: true;
|
|
1553
|
+
};
|
|
1554
|
+
evidence: {
|
|
1555
|
+
pendingEventsCommand: 'get_pending_mesh_events';
|
|
1556
|
+
ledgerCommand: 'get_mesh_ledger_slice';
|
|
1557
|
+
taskHistoryKind: 'task_dispatched' | 'task_completed' | 'task_failed';
|
|
1558
|
+
};
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
type MeshRefineBatchTerminalJob = MeshRefineBatchJobHandle & { result?: Record<string, unknown> };
|
|
1562
|
+
|
|
1499
1563
|
const REFINE_VALIDATION_CATEGORIES = ['typecheck', 'test', 'lint', 'build'] as const;
|
|
1500
1564
|
const REFINE_VALIDATION_TIMEOUT_MS = 120_000;
|
|
1501
1565
|
const REFINE_VALIDATION_OUTPUT_LIMIT_BYTES = 128 * 1024;
|
|
@@ -1581,8 +1645,46 @@ async function runMeshRefinePatchEquivalenceGate(
|
|
|
1581
1645
|
maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
|
|
1582
1646
|
});
|
|
1583
1647
|
const mergeBase = git(['merge-base', baseHead, branchHead]).trim();
|
|
1584
|
-
|
|
1585
|
-
|
|
1648
|
+
|
|
1649
|
+
// `git merge-tree --write-tree` refuses to merge gitlinks that differ
|
|
1650
|
+
// across base/branch even when the advance is a strict fast-forward,
|
|
1651
|
+
// failing with "Recursive merging with submodules currently only
|
|
1652
|
+
// supports trivial cases". When that happens we check whether the
|
|
1653
|
+
// conflict is *entirely* trivial-ff gitlinks and, if so, synthesize the
|
|
1654
|
+
// merged tree ourselves (base tree + branch-side gitlinks).
|
|
1655
|
+
let mergedTree = '';
|
|
1656
|
+
let mergeTreeStdout = '';
|
|
1657
|
+
let gitlinkTrivialFastForward: MeshRefinePatchEquivalenceSummary['gitlinkTrivialFastForward'];
|
|
1658
|
+
try {
|
|
1659
|
+
mergeTreeStdout = git(['merge-tree', '--write-tree', baseHead, branchHead]);
|
|
1660
|
+
mergedTree = mergeTreeStdout.trim().split(/\s+/)[0] || '';
|
|
1661
|
+
} catch (mergeTreeErr: any) {
|
|
1662
|
+
const output = `${mergeTreeErr?.message || ''}\n${mergeTreeErr?.stdout || ''}\n${mergeTreeErr?.stderr || ''}`;
|
|
1663
|
+
const isSubmoduleConflict = /(submodule|160000)/i.test(output)
|
|
1664
|
+
|| /Recursive merging with submodules/i.test(output);
|
|
1665
|
+
if (!isSubmoduleConflict) throw mergeTreeErr;
|
|
1666
|
+
const evaluation = evaluateGitlinkTrivialFastForward(repoRoot, baseHead, branchHead);
|
|
1667
|
+
if (!evaluation.trivial) {
|
|
1668
|
+
return {
|
|
1669
|
+
status: 'failed',
|
|
1670
|
+
equivalent: false,
|
|
1671
|
+
baseHead,
|
|
1672
|
+
branchHead,
|
|
1673
|
+
mergeBase: mergeBase || undefined,
|
|
1674
|
+
durationMs: Date.now() - startedAt,
|
|
1675
|
+
error: mergeTreeErr?.message || String(mergeTreeErr),
|
|
1676
|
+
stdout: truncateValidationOutput(mergeTreeErr?.stdout),
|
|
1677
|
+
stderr: truncateValidationOutput(mergeTreeErr?.stderr),
|
|
1678
|
+
gitlinkTrivialFastForward: { resolved: false, gitlinks: evaluation.gitlinks, reason: evaluation.reason },
|
|
1679
|
+
actionableHint: buildPatchEquivalenceSubmoduleConflictHint(repoRoot, baseHead, branchHead, output),
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
// All conflicting gitlinks fast-forward and nothing else conflicts:
|
|
1683
|
+
// synthesize the merge result as base's tree with branch-side gitlinks.
|
|
1684
|
+
mergedTree = synthesizeTrivialFastForwardMergeTree(repoRoot, baseHead, branchHead, evaluation.gitlinks) || '';
|
|
1685
|
+
gitlinkTrivialFastForward = { resolved: true, gitlinks: evaluation.gitlinks };
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1586
1688
|
if (!mergeBase || !mergedTree) {
|
|
1587
1689
|
return {
|
|
1588
1690
|
status: 'failed',
|
|
@@ -1594,6 +1696,7 @@ async function runMeshRefinePatchEquivalenceGate(
|
|
|
1594
1696
|
durationMs: Date.now() - startedAt,
|
|
1595
1697
|
error: 'patch equivalence preflight could not resolve merge-base or synthetic merge tree',
|
|
1596
1698
|
stdout: truncateValidationOutput(mergeTreeStdout),
|
|
1699
|
+
gitlinkTrivialFastForward,
|
|
1597
1700
|
};
|
|
1598
1701
|
}
|
|
1599
1702
|
const expectedPatchId = await computeGitPatchId(repoRoot, mergeBase, branchHead);
|
|
@@ -1609,6 +1712,7 @@ async function runMeshRefinePatchEquivalenceGate(
|
|
|
1609
1712
|
expectedPatchId,
|
|
1610
1713
|
actualPatchId,
|
|
1611
1714
|
durationMs: Date.now() - startedAt,
|
|
1715
|
+
gitlinkTrivialFastForward,
|
|
1612
1716
|
};
|
|
1613
1717
|
} catch (e: any) {
|
|
1614
1718
|
return {
|
|
@@ -1630,6 +1734,101 @@ async function runMeshRefinePatchEquivalenceGate(
|
|
|
1630
1734
|
}
|
|
1631
1735
|
}
|
|
1632
1736
|
|
|
1737
|
+
/**
|
|
1738
|
+
* No-op guard: detect a "silent no-op" merge before the Refinery merge runs.
|
|
1739
|
+
*
|
|
1740
|
+
* A silent no-op occurs when the refine target branch's ROOT tree is byte-identical
|
|
1741
|
+
* to the merge base (origin/main). This is the trap where a submodule (e.g. oss) has
|
|
1742
|
+
* real commits but the root branch never committed the gitlink (oss-pointer) bump, so
|
|
1743
|
+
* the root diff Refinery would merge is empty. Merging that produces a merge commit with
|
|
1744
|
+
* no content change — reported as "success" while the actual work never reaches main.
|
|
1745
|
+
*
|
|
1746
|
+
* A committed gitlink bump (the legitimate oss-pointer bump) DOES show up in the root
|
|
1747
|
+
* tree diff (as a 160000-mode entry), so this guard does NOT block legitimate refines —
|
|
1748
|
+
* it only fires when the root tree diff vs base is COMPLETELY empty.
|
|
1749
|
+
*
|
|
1750
|
+
* Runs after the patch-equivalence gate; the "already merged via other path" case
|
|
1751
|
+
* (branch has real changes already present in base) is handled upstream and never
|
|
1752
|
+
* reaches here, so an empty root diff at this point is genuinely a no-op.
|
|
1753
|
+
*/
|
|
1754
|
+
export async function runMeshRefineEffectiveDiffGate(
|
|
1755
|
+
repoRoot: string,
|
|
1756
|
+
baseHead: string,
|
|
1757
|
+
branchHead: string,
|
|
1758
|
+
): Promise<MeshRefineEffectiveDiffSummary> {
|
|
1759
|
+
const startedAt = Date.now();
|
|
1760
|
+
try {
|
|
1761
|
+
const { execFileSync } = await import('node:child_process');
|
|
1762
|
+
const git = (args: string[], opts?: { cwd?: string }) => execFileSync('git', args, {
|
|
1763
|
+
cwd: opts?.cwd || repoRoot,
|
|
1764
|
+
encoding: 'utf8',
|
|
1765
|
+
maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
|
|
1766
|
+
});
|
|
1767
|
+
// Root tree diff between base and branch. --raw surfaces gitlink (160000) entries,
|
|
1768
|
+
// so a committed submodule-pointer bump counts as an effective change. An empty
|
|
1769
|
+
// result means the branch's root tree is identical to base → nothing would merge.
|
|
1770
|
+
const rawDiff = git(['diff', '--raw', baseHead, branchHead]).trim();
|
|
1771
|
+
if (rawDiff) {
|
|
1772
|
+
const changedPaths = rawDiff
|
|
1773
|
+
.split('\n')
|
|
1774
|
+
.map(line => line.split('\t').slice(1).join('\t').trim())
|
|
1775
|
+
.filter(Boolean)
|
|
1776
|
+
.slice(0, 50);
|
|
1777
|
+
return {
|
|
1778
|
+
status: 'passed',
|
|
1779
|
+
hasEffectiveDiff: true,
|
|
1780
|
+
baseHead,
|
|
1781
|
+
branchHead,
|
|
1782
|
+
changedPaths,
|
|
1783
|
+
durationMs: Date.now() - startedAt,
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// No root diff → silent no-op. Try to surface which submodule(s) have commits that
|
|
1788
|
+
// were never captured by a committed gitlink bump, to make the message actionable.
|
|
1789
|
+
const submoduleHints: Array<{ path: string; reason: string }> = [];
|
|
1790
|
+
try {
|
|
1791
|
+
// `git submodule status` flags submodules whose checked-out commit differs from
|
|
1792
|
+
// the recorded gitlink with a leading '+'. That difference is exactly the
|
|
1793
|
+
// uncommitted-pointer-bump situation this guard exists to catch.
|
|
1794
|
+
const status = git(['submodule', 'status']);
|
|
1795
|
+
for (const line of status.split('\n')) {
|
|
1796
|
+
const trimmed = line.trimEnd();
|
|
1797
|
+
if (!trimmed) continue;
|
|
1798
|
+
if (trimmed.startsWith('+')) {
|
|
1799
|
+
const parts = trimmed.slice(1).trim().split(/\s+/);
|
|
1800
|
+
const path = parts[1] || parts[0] || '(unknown)';
|
|
1801
|
+
submoduleHints.push({
|
|
1802
|
+
path,
|
|
1803
|
+
reason: 'submodule checked-out commit differs from the committed gitlink (pointer bump not committed on the root branch)',
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
} catch { /* submodule status is best-effort */ }
|
|
1808
|
+
|
|
1809
|
+
return {
|
|
1810
|
+
status: 'failed',
|
|
1811
|
+
hasEffectiveDiff: false,
|
|
1812
|
+
baseHead,
|
|
1813
|
+
branchHead,
|
|
1814
|
+
...(submoduleHints.length ? { submoduleHints } : {}),
|
|
1815
|
+
durationMs: Date.now() - startedAt,
|
|
1816
|
+
};
|
|
1817
|
+
} catch (e: any) {
|
|
1818
|
+
// On error, do NOT block the merge — fail open so a probe failure can't wedge refine.
|
|
1819
|
+
return {
|
|
1820
|
+
status: 'skipped',
|
|
1821
|
+
hasEffectiveDiff: true,
|
|
1822
|
+
baseHead,
|
|
1823
|
+
branchHead,
|
|
1824
|
+
durationMs: Date.now() - startedAt,
|
|
1825
|
+
error: e?.message || String(e),
|
|
1826
|
+
stdout: truncateValidationOutput(e?.stdout),
|
|
1827
|
+
stderr: truncateValidationOutput(e?.stderr),
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1633
1832
|
function buildPatchEquivalenceSubmoduleConflictHint(
|
|
1634
1833
|
repoRoot: string,
|
|
1635
1834
|
baseHead: string,
|
|
@@ -1695,6 +1894,230 @@ function readTreeObject(repoRoot: string, ref: string, path: string): string | u
|
|
|
1695
1894
|
}
|
|
1696
1895
|
}
|
|
1697
1896
|
|
|
1897
|
+
/**
|
|
1898
|
+
* Resolve the absolute path to the repo's real git directory. In a linked
|
|
1899
|
+
* worktree, `.git` is a file pointing elsewhere, so we cannot assume a `.git`
|
|
1900
|
+
* subdirectory exists — a temporary index file must live in the actual git dir.
|
|
1901
|
+
*/
|
|
1902
|
+
function resolveGitDir(repoRoot: string): string {
|
|
1903
|
+
const out = execFileSync('git', ['rev-parse', '--absolute-git-dir'], {
|
|
1904
|
+
cwd: repoRoot,
|
|
1905
|
+
encoding: 'utf8',
|
|
1906
|
+
maxBuffer: 1024 * 1024,
|
|
1907
|
+
}).trim();
|
|
1908
|
+
return out;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* Result of evaluating whether a `git merge-tree --write-tree` submodule
|
|
1913
|
+
* conflict is in fact a trivial gitlink fast-forward that should pass the
|
|
1914
|
+
* patch-equivalence gate.
|
|
1915
|
+
*
|
|
1916
|
+
* `git merge-tree` (and `git merge` with the default recursive strategy)
|
|
1917
|
+
* refuses to 3-way merge gitlinks unless the case is "trivial" — and it
|
|
1918
|
+
* treats *any* gitlink that differs across merge-base/base/branch as
|
|
1919
|
+
* non-trivial, even when the branch-side commit is a strict descendant of the
|
|
1920
|
+
* base-side commit (i.e. a real fast-forward). Refinery only ever wants to
|
|
1921
|
+
* accept the branch's recorded gitlink, so a fast-forwardable bump is safe to
|
|
1922
|
+
* resolve to the branch side without any conflict.
|
|
1923
|
+
*/
|
|
1924
|
+
type GitlinkTrivialFastForwardEvaluation = {
|
|
1925
|
+
/** True only when the merge-tree conflict is *fully* explained by trivial-ff gitlinks. */
|
|
1926
|
+
trivial: boolean;
|
|
1927
|
+
/** Why the evaluation declined to treat the conflict as trivial (set when trivial=false). */
|
|
1928
|
+
reason?: string;
|
|
1929
|
+
/** Per-path detail for the changed gitlinks that were inspected. */
|
|
1930
|
+
gitlinks: Array<{
|
|
1931
|
+
path: string;
|
|
1932
|
+
baseCommit?: string;
|
|
1933
|
+
branchCommit?: string;
|
|
1934
|
+
fastForward: boolean;
|
|
1935
|
+
}>;
|
|
1936
|
+
};
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* Check, inside a submodule repo, whether `baseCommit` is an ancestor of
|
|
1940
|
+
* `branchCommit` (i.e. advancing the gitlink from base→branch is a pure
|
|
1941
|
+
* fast-forward). Returns false on any error or when either commit is missing
|
|
1942
|
+
* locally — safety first, ambiguity stays "not a fast-forward".
|
|
1943
|
+
*/
|
|
1944
|
+
function isSubmoduleFastForward(submoduleRepoPath: string, baseCommit: string, branchCommit: string): boolean {
|
|
1945
|
+
if (!baseCommit || !branchCommit) return false;
|
|
1946
|
+
if (baseCommit === branchCommit) return true;
|
|
1947
|
+
try {
|
|
1948
|
+
if (!fs.existsSync(submoduleRepoPath)) return false;
|
|
1949
|
+
// Both commits must exist locally for the ancestry check to be meaningful.
|
|
1950
|
+
execFileSync('git', ['cat-file', '-e', `${baseCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
|
|
1951
|
+
execFileSync('git', ['cat-file', '-e', `${branchCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
|
|
1952
|
+
// exit 0 ⇒ baseCommit is an ancestor of branchCommit ⇒ branch fast-forwards base.
|
|
1953
|
+
execFileSync('git', ['merge-base', '--is-ancestor', baseCommit, branchCommit], { cwd: submoduleRepoPath, stdio: 'ignore' });
|
|
1954
|
+
return true;
|
|
1955
|
+
} catch {
|
|
1956
|
+
return false;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* Read the set of paths that differ between two refs, tagging whether each is a
|
|
1962
|
+
* gitlink (submodule, mode 160000) on either side. Returns one entry per
|
|
1963
|
+
* changed path. Empty on error.
|
|
1964
|
+
*/
|
|
1965
|
+
function readChangedPathKinds(repoRoot: string, fromRef: string, toRef: string): Array<{ path: string; isGitlink: boolean }> {
|
|
1966
|
+
try {
|
|
1967
|
+
const output = execFileSync('git', ['diff', '--raw', '--no-abbrev', fromRef, toRef], {
|
|
1968
|
+
cwd: repoRoot,
|
|
1969
|
+
encoding: 'utf8',
|
|
1970
|
+
maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
|
|
1971
|
+
});
|
|
1972
|
+
const result: Array<{ path: string; isGitlink: boolean }> = [];
|
|
1973
|
+
const seen = new Set<string>();
|
|
1974
|
+
for (const line of output.split('\n')) {
|
|
1975
|
+
if (!line.trim()) continue;
|
|
1976
|
+
const metaAndPath = line.split('\t');
|
|
1977
|
+
const meta = metaAndPath[0] || '';
|
|
1978
|
+
const path = metaAndPath[metaAndPath.length - 1]?.trim();
|
|
1979
|
+
if (!path || seen.has(path)) continue;
|
|
1980
|
+
seen.add(path);
|
|
1981
|
+
const parts = meta.split(/\s+/);
|
|
1982
|
+
const isGitlink = !!(parts[0]?.includes('160000') || parts[1]?.includes('160000'));
|
|
1983
|
+
result.push({ path, isGitlink });
|
|
1984
|
+
}
|
|
1985
|
+
return result;
|
|
1986
|
+
} catch {
|
|
1987
|
+
return [];
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/**
|
|
1992
|
+
* Decide whether a merge-tree submodule conflict between base and branch is a
|
|
1993
|
+
* trivial gitlink fast-forward (and nothing else).
|
|
1994
|
+
*
|
|
1995
|
+
* The conflict is treated as trivial ONLY when:
|
|
1996
|
+
* 1. at least one changed gitlink exists,
|
|
1997
|
+
* 2. every changed gitlink fast-forwards (base-commit is an ancestor of the
|
|
1998
|
+
* branch-commit inside that submodule's repo), and
|
|
1999
|
+
* 3. the *only* paths that changed on both sides of the merge (i.e. the paths
|
|
2000
|
+
* that could possibly produce a 3-way conflict — the intersection of
|
|
2001
|
+
* mergeBase→base and mergeBase→branch changes) are gitlinks. Any
|
|
2002
|
+
* overlapping non-gitlink path means a genuine content conflict could be
|
|
2003
|
+
* hiding behind the submodule failure, so we keep the block.
|
|
2004
|
+
*
|
|
2005
|
+
* If any of these fail, the conflict is left as a genuine block. This never
|
|
2006
|
+
* passes a regular-file conflict or a diverged (non-ff) gitlink.
|
|
2007
|
+
*/
|
|
2008
|
+
export function evaluateGitlinkTrivialFastForward(
|
|
2009
|
+
repoRoot: string,
|
|
2010
|
+
baseHead: string,
|
|
2011
|
+
branchHead: string,
|
|
2012
|
+
): GitlinkTrivialFastForwardEvaluation {
|
|
2013
|
+
const changedGitlinks = readChangedGitlinkPaths(repoRoot, baseHead, branchHead).map(path => {
|
|
2014
|
+
const baseCommit = readTreeObject(repoRoot, baseHead, path);
|
|
2015
|
+
const branchCommit = readTreeObject(repoRoot, branchHead, path);
|
|
2016
|
+
const submoduleRepoPath = pathResolve(repoRoot, path);
|
|
2017
|
+
const fastForward = !!baseCommit && !!branchCommit
|
|
2018
|
+
&& isSubmoduleFastForward(submoduleRepoPath, baseCommit, branchCommit);
|
|
2019
|
+
return { path, baseCommit, branchCommit, fastForward };
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
if (changedGitlinks.length === 0) {
|
|
2023
|
+
return { trivial: false, reason: 'no_changed_gitlinks', gitlinks: changedGitlinks };
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
const nonFastForward = changedGitlinks.filter(entry => !entry.fastForward);
|
|
2027
|
+
if (nonFastForward.length > 0) {
|
|
2028
|
+
return {
|
|
2029
|
+
trivial: false,
|
|
2030
|
+
reason: `diverged_gitlinks:${nonFastForward.map(entry => entry.path).join(',')}`,
|
|
2031
|
+
gitlinks: changedGitlinks,
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// Prove there is no *other* conflict (regular files, or a gitlink that
|
|
2036
|
+
// diverged on both sides). A 3-way merge can only conflict on a path that
|
|
2037
|
+
// changed on BOTH sides relative to the merge-base. Compute that overlap and
|
|
2038
|
+
// require every overlapping path to be a gitlink — non-gitlink overlap means
|
|
2039
|
+
// a genuine content conflict that must stay blocked.
|
|
2040
|
+
let mergeBase = '';
|
|
2041
|
+
try {
|
|
2042
|
+
mergeBase = execFileSync('git', ['merge-base', baseHead, branchHead], {
|
|
2043
|
+
cwd: repoRoot,
|
|
2044
|
+
encoding: 'utf8',
|
|
2045
|
+
maxBuffer: 1024 * 1024,
|
|
2046
|
+
}).trim();
|
|
2047
|
+
} catch {
|
|
2048
|
+
return { trivial: false, reason: 'merge_base_unresolved', gitlinks: changedGitlinks };
|
|
2049
|
+
}
|
|
2050
|
+
if (!mergeBase) {
|
|
2051
|
+
return { trivial: false, reason: 'merge_base_unresolved', gitlinks: changedGitlinks };
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
const baseSideChanges = readChangedPathKinds(repoRoot, mergeBase, baseHead);
|
|
2055
|
+
const branchSideChanges = readChangedPathKinds(repoRoot, mergeBase, branchHead);
|
|
2056
|
+
const baseChangedPaths = new Map(baseSideChanges.map(entry => [entry.path, entry]));
|
|
2057
|
+
// Overlapping paths = candidates for a real 3-way conflict.
|
|
2058
|
+
const overlapping = branchSideChanges.filter(entry => baseChangedPaths.has(entry.path));
|
|
2059
|
+
const nonGitlinkOverlap = overlapping.filter(entry => {
|
|
2060
|
+
const baseEntry = baseChangedPaths.get(entry.path);
|
|
2061
|
+
return !(entry.isGitlink && baseEntry?.isGitlink);
|
|
2062
|
+
});
|
|
2063
|
+
if (nonGitlinkOverlap.length > 0) {
|
|
2064
|
+
return {
|
|
2065
|
+
trivial: false,
|
|
2066
|
+
reason: `non_gitlink_overlap:${nonGitlinkOverlap.map(entry => entry.path).join(',')}`,
|
|
2067
|
+
gitlinks: changedGitlinks,
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
return { trivial: true, gitlinks: changedGitlinks };
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* Synthesize the merge result for a trivial gitlink fast-forward: take
|
|
2076
|
+
* `baseHead`'s tree and overlay each changed gitlink's branch-side commit.
|
|
2077
|
+
* This mirrors what `git merge-tree` would have produced had it not bailed on
|
|
2078
|
+
* the submodule recursion limitation. Returns the tree SHA, or undefined on
|
|
2079
|
+
* failure. Caller must have already proven (via evaluateGitlinkTrivialFastForward)
|
|
2080
|
+
* that every changed gitlink fast-forwards and no other path conflicts.
|
|
2081
|
+
*/
|
|
2082
|
+
function synthesizeTrivialFastForwardMergeTree(
|
|
2083
|
+
repoRoot: string,
|
|
2084
|
+
baseHead: string,
|
|
2085
|
+
branchHead: string,
|
|
2086
|
+
gitlinks: Array<{ path: string; branchCommit?: string }>,
|
|
2087
|
+
): string | undefined {
|
|
2088
|
+
try {
|
|
2089
|
+
const baseTree = execFileSync('git', ['rev-parse', `${baseHead}^{tree}`], {
|
|
2090
|
+
cwd: repoRoot,
|
|
2091
|
+
encoding: 'utf8',
|
|
2092
|
+
maxBuffer: 1024 * 1024,
|
|
2093
|
+
}).trim();
|
|
2094
|
+
if (!baseTree) return undefined;
|
|
2095
|
+
const updates = gitlinks
|
|
2096
|
+
.filter(entry => entry.branchCommit)
|
|
2097
|
+
.map(entry => `160000 commit ${entry.branchCommit}\t${entry.path}`)
|
|
2098
|
+
.join('\n');
|
|
2099
|
+
if (!updates) return baseTree;
|
|
2100
|
+
const tmpIndex = pathJoin(resolveGitDir(repoRoot), `adhdev-refine-ff-${baseHead.slice(0, 12)}-${branchHead.slice(0, 12)}.index`);
|
|
2101
|
+
const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
|
|
2102
|
+
try {
|
|
2103
|
+
execFileSync('git', ['read-tree', baseTree], { cwd: repoRoot, env, stdio: 'ignore' });
|
|
2104
|
+
execFileSync('git', ['update-index', '--index-info'], {
|
|
2105
|
+
cwd: repoRoot,
|
|
2106
|
+
env,
|
|
2107
|
+
input: `${updates}\n`,
|
|
2108
|
+
encoding: 'utf8',
|
|
2109
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
2110
|
+
});
|
|
2111
|
+
const newTree = execFileSync('git', ['write-tree'], { cwd: repoRoot, env, encoding: 'utf8' }).trim();
|
|
2112
|
+
return newTree || undefined;
|
|
2113
|
+
} finally {
|
|
2114
|
+
try { fs.rmSync(tmpIndex, { force: true }); } catch { /* ignore */ }
|
|
2115
|
+
}
|
|
2116
|
+
} catch {
|
|
2117
|
+
return undefined;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
1698
2121
|
async function alignRefinerySubmodulesAfterMerge(
|
|
1699
2122
|
repoRoot: string,
|
|
1700
2123
|
previousBaseHead: string,
|
|
@@ -2520,6 +2943,10 @@ export class DaemonCommandRouter {
|
|
|
2520
2943
|
private runningRefineJobs = new Map<string, MeshRefineJobHandle>();
|
|
2521
2944
|
/** Terminal async Refinery jobs preserve a clear answer after the worktree node has been removed. */
|
|
2522
2945
|
private terminalRefineJobs = new Map<string, MeshRefineTerminalJob>();
|
|
2946
|
+
/** In-memory async batch Refinery jobs keyed by meshId (one batch convergence per mesh at a time). */
|
|
2947
|
+
private runningRefineBatchJobs = new Map<string, MeshRefineBatchJobHandle>();
|
|
2948
|
+
/** Terminal async batch Refinery jobs preserve the last batch outcome for late readers. */
|
|
2949
|
+
private terminalRefineBatchJobs = new Map<string, MeshRefineBatchTerminalJob>();
|
|
2523
2950
|
|
|
2524
2951
|
constructor(deps: CommandRouterDeps) {
|
|
2525
2952
|
this.deps = deps;
|
|
@@ -3095,6 +3522,8 @@ export class DaemonCommandRouter {
|
|
|
3095
3522
|
const skippedSessionIds: string[] = [];
|
|
3096
3523
|
const skippedLiveSessionIds: string[] = [];
|
|
3097
3524
|
const skippedCoordinatorSessionIds: string[] = [];
|
|
3525
|
+
const skippedLiveSessionReasons: Array<{ sessionId: string; reason: string }> = [];
|
|
3526
|
+
const actedLiveDelegateSessionIds: string[] = [];
|
|
3098
3527
|
const deleteUnsupportedSessionIds: string[] = [];
|
|
3099
3528
|
const recordsRemainSessionIds: string[] = [];
|
|
3100
3529
|
const errors: Array<{ sessionId: string; error: string }> = [];
|
|
@@ -3130,16 +3559,49 @@ export class DaemonCommandRouter {
|
|
|
3130
3559
|
const surfaceKind = getSessionHostSurfaceKind(record);
|
|
3131
3560
|
const liveRuntime = surfaceKind === 'live_runtime';
|
|
3132
3561
|
const coordinatorSession = readStringValue(record?.meta?.meshCoordinatorFor) === args.meshId;
|
|
3562
|
+
// A delegate session was launched by the coordinator specifically FOR this node
|
|
3563
|
+
// (meta.meshNodeId === this node). It is 1:1 bound to the node, even when the node
|
|
3564
|
+
// shares its daemon runtime with the main/other nodes. Removing the node should be
|
|
3565
|
+
// able to stop its own delegate session — the shared-daemon concern only applies to
|
|
3566
|
+
// sessions we matched by workspace alone (which could belong to the coordinator or to
|
|
3567
|
+
// a sibling node that is still active).
|
|
3568
|
+
const recordNodeId = readStringValue(record?.meta?.meshNodeId);
|
|
3569
|
+
const recordMeshNodeFor = readStringValue(record?.meta?.meshNodeFor);
|
|
3570
|
+
const delegateBoundToThisNode = !!recordNodeId
|
|
3571
|
+
&& recordNodeId === args.nodeId
|
|
3572
|
+
&& (!recordMeshNodeFor || recordMeshNodeFor === args.meshId);
|
|
3133
3573
|
if (!hasExplicitSessionIds && coordinatorSession) {
|
|
3134
3574
|
skippedSessionIds.push(sessionId);
|
|
3135
3575
|
skippedCoordinatorSessionIds.push(sessionId);
|
|
3136
3576
|
continue;
|
|
3137
3577
|
}
|
|
3138
|
-
|
|
3578
|
+
// Only the conservative shared-daemon guard for live sessions that are NOT a delegate
|
|
3579
|
+
// explicitly bound to this node. Delegate-bound live sessions fall through and are
|
|
3580
|
+
// stopped/deleted by the mode handlers below (which already record an intentional stop).
|
|
3581
|
+
if (!hasExplicitSessionIds && liveRuntime && !delegateBoundToThisNode) {
|
|
3582
|
+
skippedSessionIds.push(sessionId);
|
|
3583
|
+
skippedLiveSessionIds.push(sessionId);
|
|
3584
|
+
const matchedByWorkspaceOnly = !recordNodeId;
|
|
3585
|
+
const reason = recordNodeId && recordNodeId !== args.nodeId
|
|
3586
|
+
? `live_delegate_bound_to_other_node:${recordNodeId}`
|
|
3587
|
+
: matchedByWorkspaceOnly
|
|
3588
|
+
? 'live_session_matched_by_workspace_only_no_node_binding'
|
|
3589
|
+
: 'live_session_not_bound_to_this_node';
|
|
3590
|
+
skippedLiveSessionReasons.push({ sessionId, reason });
|
|
3591
|
+
continue;
|
|
3592
|
+
}
|
|
3593
|
+
if (!hasExplicitSessionIds && liveRuntime && delegateBoundToThisNode && args.mode === 'delete_stopped') {
|
|
3594
|
+
// delete_stopped never stops live runtimes by contract — even bound delegates.
|
|
3595
|
+
// Surface a clear reason instead of an unexplained skip so callers know to use
|
|
3596
|
+
// stop / stop_and_delete to release a still-running bound delegate.
|
|
3139
3597
|
skippedSessionIds.push(sessionId);
|
|
3140
3598
|
skippedLiveSessionIds.push(sessionId);
|
|
3599
|
+
skippedLiveSessionReasons.push({ sessionId, reason: 'live_delegate_preserved_by_delete_stopped_mode_use_stop_or_stop_and_delete' });
|
|
3141
3600
|
continue;
|
|
3142
3601
|
}
|
|
3602
|
+
if (!hasExplicitSessionIds && liveRuntime && delegateBoundToThisNode) {
|
|
3603
|
+
actedLiveDelegateSessionIds.push(sessionId);
|
|
3604
|
+
}
|
|
3143
3605
|
try {
|
|
3144
3606
|
if (args.mode === 'stop') {
|
|
3145
3607
|
if (!completed) {
|
|
@@ -3205,6 +3667,8 @@ export class DaemonCommandRouter {
|
|
|
3205
3667
|
skippedSessionIds,
|
|
3206
3668
|
skippedLiveSessionIds,
|
|
3207
3669
|
skippedCoordinatorSessionIds,
|
|
3670
|
+
...(actedLiveDelegateSessionIds.length ? { actedLiveDelegateSessionIds } : {}),
|
|
3671
|
+
...(skippedLiveSessionReasons.length ? { skippedLiveSessionReasons } : {}),
|
|
3208
3672
|
...(deleteUnsupported ? {
|
|
3209
3673
|
deleteUnsupported: true,
|
|
3210
3674
|
effectiveCleanup: args.mode === 'stop_and_delete'
|
|
@@ -3935,6 +4399,53 @@ export class DaemonCommandRouter {
|
|
|
3935
4399
|
};
|
|
3936
4400
|
}
|
|
3937
4401
|
|
|
4402
|
+
// No-op guard: block a silent no-op merge where the root tree is identical to base.
|
|
4403
|
+
// This catches the trap where a submodule has commits but the root branch never
|
|
4404
|
+
// committed the gitlink (oss-pointer) bump — merging would report success while the
|
|
4405
|
+
// real change never lands on main. A committed gitlink bump shows up in the root
|
|
4406
|
+
// diff, so legitimate oss-pointer refines pass through untouched.
|
|
4407
|
+
const effectiveDiffStarted = Date.now();
|
|
4408
|
+
const effectiveDiff = await runMeshRefineEffectiveDiffGate(repoRoot, baseHead, branchHead);
|
|
4409
|
+
recordMeshRefineStage(refineStages, 'effective_diff', effectiveDiff.status, effectiveDiffStarted, {
|
|
4410
|
+
hasEffectiveDiff: effectiveDiff.hasEffectiveDiff,
|
|
4411
|
+
changedPaths: effectiveDiff.changedPaths,
|
|
4412
|
+
submoduleHints: effectiveDiff.submoduleHints,
|
|
4413
|
+
...(effectiveDiff.error ? { error: effectiveDiff.error } : {}),
|
|
4414
|
+
});
|
|
4415
|
+
if (effectiveDiff.status === 'failed' && !effectiveDiff.hasEffectiveDiff) {
|
|
4416
|
+
const hintLines = (effectiveDiff.submoduleHints || []).map(h => ` - ${h.path}: ${h.reason}`);
|
|
4417
|
+
const message = [
|
|
4418
|
+
`Refinery no-op guard: branch '${branch}' has no effective root-tree diff against '${baseBranch}' (${baseHead.slice(0, 12)}); nothing would merge.`,
|
|
4419
|
+
'This usually means a submodule (e.g. oss) has commits but the root branch never committed the gitlink (pointer) bump, so the merge would be a silent no-op while the real change never reaches main.',
|
|
4420
|
+
hintLines.length ? `Submodules with uncommitted pointer bumps:\n${hintLines.join('\n')}` : '',
|
|
4421
|
+
`Fix: commit the submodule pointer bump on '${branch}' (git add <submodule-path> && git commit), then re-run refine.`,
|
|
4422
|
+
].filter(Boolean).join('\n');
|
|
4423
|
+
return {
|
|
4424
|
+
success: false,
|
|
4425
|
+
code: 'no_effective_diff',
|
|
4426
|
+
convergenceStatus: 'blocked_review',
|
|
4427
|
+
error: message,
|
|
4428
|
+
branch,
|
|
4429
|
+
into: baseBranch,
|
|
4430
|
+
validationSummary,
|
|
4431
|
+
patchEquivalence,
|
|
4432
|
+
effectiveDiff,
|
|
4433
|
+
refineStages,
|
|
4434
|
+
finalBranchConvergenceState: {
|
|
4435
|
+
branch,
|
|
4436
|
+
baseBranch,
|
|
4437
|
+
merged: false,
|
|
4438
|
+
removed: false,
|
|
4439
|
+
validation: 'passed',
|
|
4440
|
+
patchEquivalence: 'passed',
|
|
4441
|
+
effectiveDiff: 'no_effective_diff',
|
|
4442
|
+
status: 'blocked_review',
|
|
4443
|
+
reason: 'no_effective_diff',
|
|
4444
|
+
...(effectiveDiff.submoduleHints?.length ? { submoduleHints: effectiveDiff.submoduleHints } : {}),
|
|
4445
|
+
},
|
|
4446
|
+
};
|
|
4447
|
+
}
|
|
4448
|
+
|
|
3938
4449
|
let mergeResult: Record<string, unknown> | undefined;
|
|
3939
4450
|
const mergeStarted = Date.now();
|
|
3940
4451
|
try {
|
|
@@ -4294,10 +4805,24 @@ export class DaemonCommandRouter {
|
|
|
4294
4805
|
};
|
|
4295
4806
|
}
|
|
4296
4807
|
|
|
4297
|
-
// Execute: refine each node in order
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4808
|
+
// Execute: refine each node in order via the shared convergence core.
|
|
4809
|
+
return this.runMeshRefineBatchConvergence(meshId, orderedNodes, ordering, args);
|
|
4810
|
+
}
|
|
4811
|
+
|
|
4812
|
+
/**
|
|
4813
|
+
* Convergence core shared by the synchronous batch entry and the async batch job.
|
|
4814
|
+
* Refines each node in order: the per-node refine pipeline fetches origin/<base>
|
|
4815
|
+
* fresh, so each merged sibling advances the base before the next node's auto-rebase
|
|
4816
|
+
* + patch-equivalence re-check. A blocked/failed node is isolated; the batch
|
|
4817
|
+
* continues with the remaining nodes. Does NOT touch the per-node merge logic — it
|
|
4818
|
+
* only sequences calls to executeMeshRefineNodeSynchronously and aggregates outcomes.
|
|
4819
|
+
*/
|
|
4820
|
+
private async runMeshRefineBatchConvergence(
|
|
4821
|
+
meshId: string,
|
|
4822
|
+
orderedNodes: any[],
|
|
4823
|
+
ordering: { order: string[]; rationale?: unknown },
|
|
4824
|
+
args: any,
|
|
4825
|
+
): Promise<CommandRouterResult> {
|
|
4301
4826
|
type BatchNodeOutcome = {
|
|
4302
4827
|
nodeId: string;
|
|
4303
4828
|
workspace: string;
|
|
@@ -4372,6 +4897,261 @@ export class DaemonCommandRouter {
|
|
|
4372
4897
|
};
|
|
4373
4898
|
}
|
|
4374
4899
|
|
|
4900
|
+
private buildRefineBatchJobKey(meshId: string): string {
|
|
4901
|
+
return `${meshId}::batch`;
|
|
4902
|
+
}
|
|
4903
|
+
|
|
4904
|
+
private buildRefineBatchJobHandle(args: {
|
|
4905
|
+
meshId: string;
|
|
4906
|
+
nodeIds: string[];
|
|
4907
|
+
order: string[];
|
|
4908
|
+
status?: MeshRefineBatchJobStatus;
|
|
4909
|
+
startedAt?: string;
|
|
4910
|
+
completedAt?: string;
|
|
4911
|
+
jobId?: string;
|
|
4912
|
+
interactionId?: string;
|
|
4913
|
+
coordinatorDaemonId?: string;
|
|
4914
|
+
}): MeshRefineBatchJobHandle {
|
|
4915
|
+
return {
|
|
4916
|
+
success: true,
|
|
4917
|
+
async: true,
|
|
4918
|
+
batch: true,
|
|
4919
|
+
status: args.status || 'accepted',
|
|
4920
|
+
jobId: args.jobId || `refine_batch_${createInteractionId()}`,
|
|
4921
|
+
interactionId: args.interactionId || createInteractionId(),
|
|
4922
|
+
meshId: args.meshId,
|
|
4923
|
+
batchLabel: `batch:${args.nodeIds.length} node${args.nodeIds.length === 1 ? '' : 's'}`,
|
|
4924
|
+
nodeIds: args.nodeIds,
|
|
4925
|
+
nodeCount: args.nodeIds.length,
|
|
4926
|
+
order: args.order,
|
|
4927
|
+
startedAt: args.startedAt || new Date().toISOString(),
|
|
4928
|
+
...(args.completedAt ? { completedAt: args.completedAt } : {}),
|
|
4929
|
+
...(args.coordinatorDaemonId ? { targetCoordinatorDaemonId: args.coordinatorDaemonId } : {}),
|
|
4930
|
+
eventDelivery: { pendingEvents: true, ledger: true },
|
|
4931
|
+
evidence: {
|
|
4932
|
+
pendingEventsCommand: 'get_pending_mesh_events',
|
|
4933
|
+
ledgerCommand: 'get_mesh_ledger_slice',
|
|
4934
|
+
taskHistoryKind: args.status === 'completed' ? 'task_completed' : args.status === 'failed' ? 'task_failed' : 'task_dispatched',
|
|
4935
|
+
},
|
|
4936
|
+
};
|
|
4937
|
+
}
|
|
4938
|
+
|
|
4939
|
+
/**
|
|
4940
|
+
* Emit a batch Refinery terminal/accepted event through the SAME pending-event +
|
|
4941
|
+
* forward mechanism single-node refine uses (queueRefineJobEvent), so the
|
|
4942
|
+
* coordinator's existing refine:accepted/completed/failed handling and message
|
|
4943
|
+
* renderer apply unchanged. The aggregate per-node results ride along in `result`.
|
|
4944
|
+
*/
|
|
4945
|
+
private queueRefineBatchJobEvent(
|
|
4946
|
+
event: 'refine:accepted' | 'refine:completed' | 'refine:failed',
|
|
4947
|
+
handle: MeshRefineBatchJobHandle,
|
|
4948
|
+
result?: Record<string, unknown>,
|
|
4949
|
+
): void {
|
|
4950
|
+
const metadataEvent = {
|
|
4951
|
+
source: 'refine_mesh_node_async_job',
|
|
4952
|
+
batch: true,
|
|
4953
|
+
jobId: handle.jobId,
|
|
4954
|
+
interactionId: handle.interactionId,
|
|
4955
|
+
meshId: handle.meshId,
|
|
4956
|
+
nodeId: handle.batchLabel,
|
|
4957
|
+
nodeIds: handle.nodeIds,
|
|
4958
|
+
workspace: undefined,
|
|
4959
|
+
status: handle.status,
|
|
4960
|
+
startedAt: handle.startedAt,
|
|
4961
|
+
completedAt: handle.completedAt,
|
|
4962
|
+
order: handle.order,
|
|
4963
|
+
...(result ? { result } : {}),
|
|
4964
|
+
};
|
|
4965
|
+
const eventPayload = {
|
|
4966
|
+
event,
|
|
4967
|
+
meshId: handle.meshId,
|
|
4968
|
+
nodeLabel: handle.batchLabel,
|
|
4969
|
+
nodeId: handle.batchLabel,
|
|
4970
|
+
metadataEvent,
|
|
4971
|
+
queuedAt: Date.now(),
|
|
4972
|
+
...(handle.targetCoordinatorDaemonId ? { targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId } : {}),
|
|
4973
|
+
};
|
|
4974
|
+
if (typeof this.deps.instanceManager?.getByCategory === 'function') {
|
|
4975
|
+
const forwarded = handleMeshForwardEvent(
|
|
4976
|
+
{ instanceManager: this.deps.instanceManager } as any,
|
|
4977
|
+
{
|
|
4978
|
+
event,
|
|
4979
|
+
meshId: handle.meshId,
|
|
4980
|
+
nodeId: handle.batchLabel,
|
|
4981
|
+
jobId: handle.jobId,
|
|
4982
|
+
interactionId: handle.interactionId,
|
|
4983
|
+
status: handle.status,
|
|
4984
|
+
startedAt: handle.startedAt,
|
|
4985
|
+
completedAt: handle.completedAt,
|
|
4986
|
+
...(result ? { result } : {}),
|
|
4987
|
+
},
|
|
4988
|
+
);
|
|
4989
|
+
if (forwarded?.success === true) return;
|
|
4990
|
+
LOG.warn('Mesh', `[Refinery] Failed to forward async refine batch event ${event}: ${forwarded?.error || 'unknown error'}`);
|
|
4991
|
+
}
|
|
4992
|
+
queuePendingMeshCoordinatorEvent(eventPayload);
|
|
4993
|
+
}
|
|
4994
|
+
|
|
4995
|
+
private async appendRefineBatchJobLedger(
|
|
4996
|
+
kind: 'task_dispatched' | 'task_completed' | 'task_failed',
|
|
4997
|
+
handle: MeshRefineBatchJobHandle,
|
|
4998
|
+
result?: Record<string, unknown>,
|
|
4999
|
+
): Promise<void> {
|
|
5000
|
+
try {
|
|
5001
|
+
const { appendLedgerEntry } = await import('../mesh/mesh-ledger.js');
|
|
5002
|
+
appendLedgerEntry(handle.meshId, {
|
|
5003
|
+
kind,
|
|
5004
|
+
nodeId: handle.batchLabel,
|
|
5005
|
+
payload: {
|
|
5006
|
+
source: 'refine_mesh_node_async_job',
|
|
5007
|
+
refineJob: {
|
|
5008
|
+
batch: true,
|
|
5009
|
+
jobId: handle.jobId,
|
|
5010
|
+
interactionId: handle.interactionId,
|
|
5011
|
+
status: handle.status,
|
|
5012
|
+
meshId: handle.meshId,
|
|
5013
|
+
nodeIds: handle.nodeIds,
|
|
5014
|
+
order: handle.order,
|
|
5015
|
+
targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId,
|
|
5016
|
+
startedAt: handle.startedAt,
|
|
5017
|
+
completedAt: handle.completedAt,
|
|
5018
|
+
},
|
|
5019
|
+
async: true,
|
|
5020
|
+
batch: true,
|
|
5021
|
+
...(result ? {
|
|
5022
|
+
success: result.success === true,
|
|
5023
|
+
result,
|
|
5024
|
+
} : {}),
|
|
5025
|
+
},
|
|
5026
|
+
});
|
|
5027
|
+
} catch (e: any) {
|
|
5028
|
+
LOG.warn('Mesh', `[Refinery] Failed to append async refine batch ledger entry: ${e?.message || e}`);
|
|
5029
|
+
}
|
|
5030
|
+
}
|
|
5031
|
+
|
|
5032
|
+
private async finishMeshRefineBatchJob(
|
|
5033
|
+
handle: MeshRefineBatchJobHandle,
|
|
5034
|
+
orderedNodes: any[],
|
|
5035
|
+
ordering: { order: string[]; rationale?: unknown },
|
|
5036
|
+
args: any,
|
|
5037
|
+
): Promise<void> {
|
|
5038
|
+
const key = this.buildRefineBatchJobKey(handle.meshId);
|
|
5039
|
+
let result: Record<string, unknown>;
|
|
5040
|
+
try {
|
|
5041
|
+
result = await this.runMeshRefineBatchConvergence(handle.meshId, orderedNodes, ordering, args) as Record<string, unknown>;
|
|
5042
|
+
} catch (e: any) {
|
|
5043
|
+
result = { success: false, error: e?.message || String(e), batch: true };
|
|
5044
|
+
}
|
|
5045
|
+
const completedAt = new Date().toISOString();
|
|
5046
|
+
|
|
5047
|
+
// The batch as a whole "completed" only when every node converged (no blocked /
|
|
5048
|
+
// not_mergeable). A partial batch is reported as a terminal failure so the
|
|
5049
|
+
// coordinator inspects the per-node blockers rather than assuming a clean merge.
|
|
5050
|
+
const summary = (result.summary && typeof result.summary === 'object') ? result.summary as Record<string, number> : undefined;
|
|
5051
|
+
const allConverged = result.allConverged === true;
|
|
5052
|
+
const isTerminalSuccess = result.success === true && allConverged;
|
|
5053
|
+
|
|
5054
|
+
const nextStep = typeof result.nextStep === 'string' && result.nextStep
|
|
5055
|
+
? result.nextStep
|
|
5056
|
+
: isTerminalSuccess
|
|
5057
|
+
? 'All batched nodes converged onto base. Continue from the updated mesh state.'
|
|
5058
|
+
: 'Resolve blocked_review / not_mergeable nodes (see per-node code/stage/error in result.results), then re-run mesh_refine_batch for the remaining nodes.';
|
|
5059
|
+
const normalizedResult = {
|
|
5060
|
+
...result,
|
|
5061
|
+
batch: true,
|
|
5062
|
+
nextStep,
|
|
5063
|
+
...(summary ? {
|
|
5064
|
+
convergenceStatus: allConverged ? 'all_converged' : 'partial',
|
|
5065
|
+
} : {}),
|
|
5066
|
+
};
|
|
5067
|
+
|
|
5068
|
+
const terminalHandle = this.buildRefineBatchJobHandle({
|
|
5069
|
+
meshId: handle.meshId,
|
|
5070
|
+
nodeIds: handle.nodeIds,
|
|
5071
|
+
order: handle.order,
|
|
5072
|
+
status: isTerminalSuccess ? 'completed' : 'failed',
|
|
5073
|
+
startedAt: handle.startedAt,
|
|
5074
|
+
completedAt,
|
|
5075
|
+
jobId: handle.jobId,
|
|
5076
|
+
interactionId: handle.interactionId,
|
|
5077
|
+
coordinatorDaemonId: handle.targetCoordinatorDaemonId,
|
|
5078
|
+
});
|
|
5079
|
+
const terminal: MeshRefineBatchTerminalJob = { ...terminalHandle, result: normalizedResult };
|
|
5080
|
+
this.terminalRefineBatchJobs.set(key, terminal);
|
|
5081
|
+
this.runningRefineBatchJobs.delete(key);
|
|
5082
|
+
this.invalidateAggregateMeshStatus(handle.meshId);
|
|
5083
|
+
await this.appendRefineBatchJobLedger(isTerminalSuccess ? 'task_completed' : 'task_failed', terminalHandle, normalizedResult);
|
|
5084
|
+
this.queueRefineBatchJobEvent(isTerminalSuccess ? 'refine:completed' : 'refine:failed', terminalHandle, normalizedResult);
|
|
5085
|
+
}
|
|
5086
|
+
|
|
5087
|
+
/**
|
|
5088
|
+
* Async entry for the batch Refinery execute path. Mirrors startMeshRefineJob:
|
|
5089
|
+
* resolves the plan synchronously (so target/ordering errors and the dry-run shape
|
|
5090
|
+
* stay synchronous), then for execute=true registers an in-flight batch job, returns
|
|
5091
|
+
* {async:true, status:'accepted', batch:true, ...plan} immediately, and runs the
|
|
5092
|
+
* convergence loop in the background — emitting the same terminal refine event.
|
|
5093
|
+
* Idempotent: a batch already in flight for this mesh returns the running handle
|
|
5094
|
+
* with duplicate:true rather than spawning a second background job.
|
|
5095
|
+
*/
|
|
5096
|
+
private async startMeshRefineBatchJob(meshId: string, requestedNodeIds: string[] | undefined, args: any): Promise<CommandRouterResult> {
|
|
5097
|
+
// Resolve the plan up-front. For dry-run this returns the synchronous plan; for
|
|
5098
|
+
// execute it returns the same plan shape but we hand convergence to the bg job.
|
|
5099
|
+
const plan = await this.batchRefineMeshNodes(meshId, requestedNodeIds, { ...args, dryRun: true, execute: false });
|
|
5100
|
+
const planRecord = plan as Record<string, unknown>;
|
|
5101
|
+
if (planRecord.success !== true) return plan;
|
|
5102
|
+
|
|
5103
|
+
// If the caller actually asked for a dry-run, return the plan as-is (sync).
|
|
5104
|
+
if (args?.dryRun === true && args?.execute !== true) return plan;
|
|
5105
|
+
|
|
5106
|
+
const order = Array.isArray(planRecord.order) ? (planRecord.order as unknown[]).filter((v): v is string => typeof v === 'string') : [];
|
|
5107
|
+
const nodeIds = order.slice();
|
|
5108
|
+
if (nodeIds.length === 0) {
|
|
5109
|
+
// No convergeable nodes — nothing to dispatch; return the empty plan synchronously.
|
|
5110
|
+
return { ...planRecord, success: true, batch: true, dryRun: false, async: false };
|
|
5111
|
+
}
|
|
5112
|
+
|
|
5113
|
+
const key = this.buildRefineBatchJobKey(meshId);
|
|
5114
|
+
const running = this.runningRefineBatchJobs.get(key);
|
|
5115
|
+
if (running) return { ...running, duplicate: true };
|
|
5116
|
+
|
|
5117
|
+
// Re-resolve the ordered node objects against current membership so the bg job
|
|
5118
|
+
// refines real nodes (the plan only carries ids). preferInline matches refine_mesh_node.
|
|
5119
|
+
const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
5120
|
+
const mesh = meshRecord?.mesh;
|
|
5121
|
+
const allNodes: any[] = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
|
|
5122
|
+
const orderedNodes = nodeIds
|
|
5123
|
+
.map(id => allNodes.find(n => n.id === id || n.nodeId === id))
|
|
5124
|
+
.filter((n): n is any => !!n);
|
|
5125
|
+
if (orderedNodes.length === 0) {
|
|
5126
|
+
return { success: false, error: 'Batch nodes no longer resolvable in mesh', batch: true };
|
|
5127
|
+
}
|
|
5128
|
+
const ordering = {
|
|
5129
|
+
order,
|
|
5130
|
+
rationale: planRecord.orderingRationale,
|
|
5131
|
+
};
|
|
5132
|
+
|
|
5133
|
+
const coordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
|
|
5134
|
+
? args.coordinatorDaemonId.trim()
|
|
5135
|
+
: (this.deps.statusInstanceId || undefined);
|
|
5136
|
+
const handle = this.buildRefineBatchJobHandle({ meshId, nodeIds, order, coordinatorDaemonId });
|
|
5137
|
+
this.runningRefineBatchJobs.set(key, handle);
|
|
5138
|
+
await this.appendRefineBatchJobLedger('task_dispatched', handle);
|
|
5139
|
+
this.queueRefineBatchJobEvent('refine:accepted', handle);
|
|
5140
|
+
|
|
5141
|
+
setImmediate(() => {
|
|
5142
|
+
void this.finishMeshRefineBatchJob(handle, orderedNodes, ordering, args);
|
|
5143
|
+
});
|
|
5144
|
+
|
|
5145
|
+
// Return the accepted handle plus the plan so the coordinator sees the target set.
|
|
5146
|
+
return {
|
|
5147
|
+
...handle,
|
|
5148
|
+
order,
|
|
5149
|
+
orderingRationale: planRecord.orderingRationale,
|
|
5150
|
+
plan: planRecord.plan,
|
|
5151
|
+
note: 'Batch convergence accepted and running in the background. Completion/failure (with per-node results) will be delivered as a terminal refine event; do not poll repeatedly.',
|
|
5152
|
+
};
|
|
5153
|
+
}
|
|
5154
|
+
|
|
4375
5155
|
private async finishMeshRefineJob(handle: MeshRefineJobHandle, args: any): Promise<void> {
|
|
4376
5156
|
const key = this.buildRefineJobKey(handle.meshId, handle.targetNodeId);
|
|
4377
5157
|
let result: Record<string, unknown>;
|
|
@@ -5041,7 +5821,11 @@ export class DaemonCommandRouter {
|
|
|
5041
5821
|
version: this.deps.statusVersion || 'unknown',
|
|
5042
5822
|
profile: 'metadata',
|
|
5043
5823
|
});
|
|
5044
|
-
|
|
5824
|
+
// Surface the daemon's build stamp so coordinators (mesh_status)
|
|
5825
|
+
// can detect a running daemon that predates a just-merged fix and
|
|
5826
|
+
// is awaiting deploy/restart. Sibling of `status` to avoid
|
|
5827
|
+
// perturbing the dashboard status snapshot shape.
|
|
5828
|
+
return { success: true, status: snapshot, daemonBuild: getDaemonBuildInfo() };
|
|
5045
5829
|
}
|
|
5046
5830
|
|
|
5047
5831
|
case 'get_machine_runtime_stats': {
|
|
@@ -6144,6 +6928,7 @@ export class DaemonCommandRouter {
|
|
|
6144
6928
|
? args.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
|
|
6145
6929
|
: undefined;
|
|
6146
6930
|
let nodeDaemonId: string | undefined;
|
|
6931
|
+
let allowAutoPublishSubmoduleMainCommits = false;
|
|
6147
6932
|
if (meshId && nodeId) {
|
|
6148
6933
|
// preferInline so fast-forward can resolve inline-cache-only clone worktree nodes.
|
|
6149
6934
|
const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
@@ -6155,6 +6940,7 @@ export class DaemonCommandRouter {
|
|
|
6155
6940
|
if (!submoduleIgnorePaths && Array.isArray(node?.policy?.submoduleIgnorePaths)) {
|
|
6156
6941
|
submoduleIgnorePaths = node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string');
|
|
6157
6942
|
}
|
|
6943
|
+
allowAutoPublishSubmoduleMainCommits = mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true;
|
|
6158
6944
|
nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
|
|
6159
6945
|
}
|
|
6160
6946
|
// If the target node belongs to a remote daemon, forward the command there.
|
|
@@ -6179,6 +6965,9 @@ export class DaemonCommandRouter {
|
|
|
6179
6965
|
dryRun: args?.dryRun === true,
|
|
6180
6966
|
updateSubmodules: args?.updateSubmodules === true,
|
|
6181
6967
|
submoduleIgnorePaths,
|
|
6968
|
+
mode: args?.mode === 'push' ? 'push' : 'merge',
|
|
6969
|
+
pushSubmodules: args?.pushSubmodules === true,
|
|
6970
|
+
allowAutoPublishSubmoduleMainCommits,
|
|
6182
6971
|
}) as Promise<unknown>);
|
|
6183
6972
|
return result as CommandRouterResult;
|
|
6184
6973
|
}
|
|
@@ -6196,7 +6985,14 @@ export class DaemonCommandRouter {
|
|
|
6196
6985
|
const requestedNodeIds = Array.isArray(args?.nodeIds)
|
|
6197
6986
|
? (args.nodeIds as unknown[]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0).map(v => v.trim())
|
|
6198
6987
|
: undefined;
|
|
6199
|
-
|
|
6988
|
+
// Dry-run (plan-only) stays synchronous: it does no validation/merge and
|
|
6989
|
+
// returns instantly. Execute goes through the async batch job — immediate
|
|
6990
|
+
// {async:true, status:'accepted'} + background convergence + terminal event,
|
|
6991
|
+
// matching the single-node refine_mesh_node contract so long validation
|
|
6992
|
+
// suites can't time out the IPC and strand the coordinator.
|
|
6993
|
+
const isDryRun = args?.dryRun !== false && args?.execute !== true;
|
|
6994
|
+
if (isDryRun) return this.batchRefineMeshNodes(meshId, requestedNodeIds, args);
|
|
6995
|
+
return this.startMeshRefineBatchJob(meshId, requestedNodeIds, args);
|
|
6200
6996
|
}
|
|
6201
6997
|
|
|
6202
6998
|
case 'remove_mesh_node': {
|