@adhdev/daemon-core 0.9.82-rc.260 → 0.9.82-rc.262
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/commands/router.d.ts +116 -0
- package/dist/index.js +585 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +585 -10
- package/dist/index.mjs.map +1 -1
- package/dist/providers/spec/cli-adapter.d.ts +38 -0
- package/dist/providers/spec/fsm-driver.d.ts +41 -0
- package/package.json +1 -1
- package/src/commands/router.ts +756 -7
- package/src/providers/spec/adapter.ts +10 -3
- package/src/providers/spec/cli-adapter.ts +80 -0
- package/src/providers/spec/fsm-driver.ts +60 -3
package/src/commands/router.ts
CHANGED
|
@@ -1397,6 +1397,31 @@ type MeshRefinePatchEquivalenceSummary = {
|
|
|
1397
1397
|
stdout?: string;
|
|
1398
1398
|
stderr?: string;
|
|
1399
1399
|
actionableHint?: MeshRefineSubmoduleConflictHint;
|
|
1400
|
+
/**
|
|
1401
|
+
* Set when a `merge-tree` submodule conflict was reclassified as a trivial
|
|
1402
|
+
* gitlink fast-forward and the gate passed via a synthesized merge tree.
|
|
1403
|
+
*/
|
|
1404
|
+
gitlinkTrivialFastForward?: {
|
|
1405
|
+
resolved: boolean;
|
|
1406
|
+
gitlinks: Array<{ path: string; baseCommit?: string; branchCommit?: string; fastForward: boolean }>;
|
|
1407
|
+
reason?: string;
|
|
1408
|
+
};
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
type MeshRefineEffectiveDiffSummary = {
|
|
1412
|
+
status: MeshRefineStageStatus;
|
|
1413
|
+
/** True when there is at least one root-tree change between base and branch (incl. gitlink bumps). */
|
|
1414
|
+
hasEffectiveDiff: boolean;
|
|
1415
|
+
baseHead: string;
|
|
1416
|
+
branchHead: string;
|
|
1417
|
+
/** Root-level paths that differ between base and branch (capped). */
|
|
1418
|
+
changedPaths?: string[];
|
|
1419
|
+
/** Submodule paths with uncommitted/divergent commits but NO committed gitlink bump in the root tree. */
|
|
1420
|
+
submoduleHints?: Array<{ path: string; reason: string }>;
|
|
1421
|
+
durationMs: number;
|
|
1422
|
+
error?: string;
|
|
1423
|
+
stdout?: string;
|
|
1424
|
+
stderr?: string;
|
|
1400
1425
|
};
|
|
1401
1426
|
|
|
1402
1427
|
type MeshRefineSubmoduleConflictHint = {
|
|
@@ -1496,6 +1521,44 @@ type MeshRefineJobHandle = {
|
|
|
1496
1521
|
|
|
1497
1522
|
type MeshRefineTerminalJob = MeshRefineJobHandle & { result?: Record<string, unknown> };
|
|
1498
1523
|
|
|
1524
|
+
type MeshRefineBatchJobStatus = 'accepted' | 'completed' | 'failed';
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Async handle returned by the batch Refinery the instant a convergence run is
|
|
1528
|
+
* accepted. Mirrors {@link MeshRefineJobHandle} (async:true / status:'accepted' +
|
|
1529
|
+
* terminal pending-event + ledger delivery) but scopes a whole batch of sibling
|
|
1530
|
+
* nodes rather than a single node. The synthetic `batchLabel` is used as the
|
|
1531
|
+
* `nodeLabel` for the shared refine event/message renderer.
|
|
1532
|
+
*/
|
|
1533
|
+
type MeshRefineBatchJobHandle = {
|
|
1534
|
+
success: true;
|
|
1535
|
+
async: true;
|
|
1536
|
+
batch: true;
|
|
1537
|
+
status: MeshRefineBatchJobStatus;
|
|
1538
|
+
jobId: string;
|
|
1539
|
+
interactionId: string;
|
|
1540
|
+
meshId: string;
|
|
1541
|
+
batchLabel: string;
|
|
1542
|
+
nodeIds: string[];
|
|
1543
|
+
nodeCount: number;
|
|
1544
|
+
order: string[];
|
|
1545
|
+
startedAt: string;
|
|
1546
|
+
completedAt?: string;
|
|
1547
|
+
duplicate?: boolean;
|
|
1548
|
+
targetCoordinatorDaemonId?: string;
|
|
1549
|
+
eventDelivery: {
|
|
1550
|
+
pendingEvents: true;
|
|
1551
|
+
ledger: true;
|
|
1552
|
+
};
|
|
1553
|
+
evidence: {
|
|
1554
|
+
pendingEventsCommand: 'get_pending_mesh_events';
|
|
1555
|
+
ledgerCommand: 'get_mesh_ledger_slice';
|
|
1556
|
+
taskHistoryKind: 'task_dispatched' | 'task_completed' | 'task_failed';
|
|
1557
|
+
};
|
|
1558
|
+
};
|
|
1559
|
+
|
|
1560
|
+
type MeshRefineBatchTerminalJob = MeshRefineBatchJobHandle & { result?: Record<string, unknown> };
|
|
1561
|
+
|
|
1499
1562
|
const REFINE_VALIDATION_CATEGORIES = ['typecheck', 'test', 'lint', 'build'] as const;
|
|
1500
1563
|
const REFINE_VALIDATION_TIMEOUT_MS = 120_000;
|
|
1501
1564
|
const REFINE_VALIDATION_OUTPUT_LIMIT_BYTES = 128 * 1024;
|
|
@@ -1581,8 +1644,46 @@ async function runMeshRefinePatchEquivalenceGate(
|
|
|
1581
1644
|
maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
|
|
1582
1645
|
});
|
|
1583
1646
|
const mergeBase = git(['merge-base', baseHead, branchHead]).trim();
|
|
1584
|
-
|
|
1585
|
-
|
|
1647
|
+
|
|
1648
|
+
// `git merge-tree --write-tree` refuses to merge gitlinks that differ
|
|
1649
|
+
// across base/branch even when the advance is a strict fast-forward,
|
|
1650
|
+
// failing with "Recursive merging with submodules currently only
|
|
1651
|
+
// supports trivial cases". When that happens we check whether the
|
|
1652
|
+
// conflict is *entirely* trivial-ff gitlinks and, if so, synthesize the
|
|
1653
|
+
// merged tree ourselves (base tree + branch-side gitlinks).
|
|
1654
|
+
let mergedTree = '';
|
|
1655
|
+
let mergeTreeStdout = '';
|
|
1656
|
+
let gitlinkTrivialFastForward: MeshRefinePatchEquivalenceSummary['gitlinkTrivialFastForward'];
|
|
1657
|
+
try {
|
|
1658
|
+
mergeTreeStdout = git(['merge-tree', '--write-tree', baseHead, branchHead]);
|
|
1659
|
+
mergedTree = mergeTreeStdout.trim().split(/\s+/)[0] || '';
|
|
1660
|
+
} catch (mergeTreeErr: any) {
|
|
1661
|
+
const output = `${mergeTreeErr?.message || ''}\n${mergeTreeErr?.stdout || ''}\n${mergeTreeErr?.stderr || ''}`;
|
|
1662
|
+
const isSubmoduleConflict = /(submodule|160000)/i.test(output)
|
|
1663
|
+
|| /Recursive merging with submodules/i.test(output);
|
|
1664
|
+
if (!isSubmoduleConflict) throw mergeTreeErr;
|
|
1665
|
+
const evaluation = evaluateGitlinkTrivialFastForward(repoRoot, baseHead, branchHead);
|
|
1666
|
+
if (!evaluation.trivial) {
|
|
1667
|
+
return {
|
|
1668
|
+
status: 'failed',
|
|
1669
|
+
equivalent: false,
|
|
1670
|
+
baseHead,
|
|
1671
|
+
branchHead,
|
|
1672
|
+
mergeBase: mergeBase || undefined,
|
|
1673
|
+
durationMs: Date.now() - startedAt,
|
|
1674
|
+
error: mergeTreeErr?.message || String(mergeTreeErr),
|
|
1675
|
+
stdout: truncateValidationOutput(mergeTreeErr?.stdout),
|
|
1676
|
+
stderr: truncateValidationOutput(mergeTreeErr?.stderr),
|
|
1677
|
+
gitlinkTrivialFastForward: { resolved: false, gitlinks: evaluation.gitlinks, reason: evaluation.reason },
|
|
1678
|
+
actionableHint: buildPatchEquivalenceSubmoduleConflictHint(repoRoot, baseHead, branchHead, output),
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
// All conflicting gitlinks fast-forward and nothing else conflicts:
|
|
1682
|
+
// synthesize the merge result as base's tree with branch-side gitlinks.
|
|
1683
|
+
mergedTree = synthesizeTrivialFastForwardMergeTree(repoRoot, baseHead, branchHead, evaluation.gitlinks) || '';
|
|
1684
|
+
gitlinkTrivialFastForward = { resolved: true, gitlinks: evaluation.gitlinks };
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1586
1687
|
if (!mergeBase || !mergedTree) {
|
|
1587
1688
|
return {
|
|
1588
1689
|
status: 'failed',
|
|
@@ -1594,6 +1695,7 @@ async function runMeshRefinePatchEquivalenceGate(
|
|
|
1594
1695
|
durationMs: Date.now() - startedAt,
|
|
1595
1696
|
error: 'patch equivalence preflight could not resolve merge-base or synthetic merge tree',
|
|
1596
1697
|
stdout: truncateValidationOutput(mergeTreeStdout),
|
|
1698
|
+
gitlinkTrivialFastForward,
|
|
1597
1699
|
};
|
|
1598
1700
|
}
|
|
1599
1701
|
const expectedPatchId = await computeGitPatchId(repoRoot, mergeBase, branchHead);
|
|
@@ -1609,6 +1711,7 @@ async function runMeshRefinePatchEquivalenceGate(
|
|
|
1609
1711
|
expectedPatchId,
|
|
1610
1712
|
actualPatchId,
|
|
1611
1713
|
durationMs: Date.now() - startedAt,
|
|
1714
|
+
gitlinkTrivialFastForward,
|
|
1612
1715
|
};
|
|
1613
1716
|
} catch (e: any) {
|
|
1614
1717
|
return {
|
|
@@ -1630,6 +1733,101 @@ async function runMeshRefinePatchEquivalenceGate(
|
|
|
1630
1733
|
}
|
|
1631
1734
|
}
|
|
1632
1735
|
|
|
1736
|
+
/**
|
|
1737
|
+
* No-op guard: detect a "silent no-op" merge before the Refinery merge runs.
|
|
1738
|
+
*
|
|
1739
|
+
* A silent no-op occurs when the refine target branch's ROOT tree is byte-identical
|
|
1740
|
+
* to the merge base (origin/main). This is the trap where a submodule (e.g. oss) has
|
|
1741
|
+
* real commits but the root branch never committed the gitlink (oss-pointer) bump, so
|
|
1742
|
+
* the root diff Refinery would merge is empty. Merging that produces a merge commit with
|
|
1743
|
+
* no content change — reported as "success" while the actual work never reaches main.
|
|
1744
|
+
*
|
|
1745
|
+
* A committed gitlink bump (the legitimate oss-pointer bump) DOES show up in the root
|
|
1746
|
+
* tree diff (as a 160000-mode entry), so this guard does NOT block legitimate refines —
|
|
1747
|
+
* it only fires when the root tree diff vs base is COMPLETELY empty.
|
|
1748
|
+
*
|
|
1749
|
+
* Runs after the patch-equivalence gate; the "already merged via other path" case
|
|
1750
|
+
* (branch has real changes already present in base) is handled upstream and never
|
|
1751
|
+
* reaches here, so an empty root diff at this point is genuinely a no-op.
|
|
1752
|
+
*/
|
|
1753
|
+
export async function runMeshRefineEffectiveDiffGate(
|
|
1754
|
+
repoRoot: string,
|
|
1755
|
+
baseHead: string,
|
|
1756
|
+
branchHead: string,
|
|
1757
|
+
): Promise<MeshRefineEffectiveDiffSummary> {
|
|
1758
|
+
const startedAt = Date.now();
|
|
1759
|
+
try {
|
|
1760
|
+
const { execFileSync } = await import('node:child_process');
|
|
1761
|
+
const git = (args: string[], opts?: { cwd?: string }) => execFileSync('git', args, {
|
|
1762
|
+
cwd: opts?.cwd || repoRoot,
|
|
1763
|
+
encoding: 'utf8',
|
|
1764
|
+
maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
|
|
1765
|
+
});
|
|
1766
|
+
// Root tree diff between base and branch. --raw surfaces gitlink (160000) entries,
|
|
1767
|
+
// so a committed submodule-pointer bump counts as an effective change. An empty
|
|
1768
|
+
// result means the branch's root tree is identical to base → nothing would merge.
|
|
1769
|
+
const rawDiff = git(['diff', '--raw', baseHead, branchHead]).trim();
|
|
1770
|
+
if (rawDiff) {
|
|
1771
|
+
const changedPaths = rawDiff
|
|
1772
|
+
.split('\n')
|
|
1773
|
+
.map(line => line.split('\t').slice(1).join('\t').trim())
|
|
1774
|
+
.filter(Boolean)
|
|
1775
|
+
.slice(0, 50);
|
|
1776
|
+
return {
|
|
1777
|
+
status: 'passed',
|
|
1778
|
+
hasEffectiveDiff: true,
|
|
1779
|
+
baseHead,
|
|
1780
|
+
branchHead,
|
|
1781
|
+
changedPaths,
|
|
1782
|
+
durationMs: Date.now() - startedAt,
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// No root diff → silent no-op. Try to surface which submodule(s) have commits that
|
|
1787
|
+
// were never captured by a committed gitlink bump, to make the message actionable.
|
|
1788
|
+
const submoduleHints: Array<{ path: string; reason: string }> = [];
|
|
1789
|
+
try {
|
|
1790
|
+
// `git submodule status` flags submodules whose checked-out commit differs from
|
|
1791
|
+
// the recorded gitlink with a leading '+'. That difference is exactly the
|
|
1792
|
+
// uncommitted-pointer-bump situation this guard exists to catch.
|
|
1793
|
+
const status = git(['submodule', 'status']);
|
|
1794
|
+
for (const line of status.split('\n')) {
|
|
1795
|
+
const trimmed = line.trimEnd();
|
|
1796
|
+
if (!trimmed) continue;
|
|
1797
|
+
if (trimmed.startsWith('+')) {
|
|
1798
|
+
const parts = trimmed.slice(1).trim().split(/\s+/);
|
|
1799
|
+
const path = parts[1] || parts[0] || '(unknown)';
|
|
1800
|
+
submoduleHints.push({
|
|
1801
|
+
path,
|
|
1802
|
+
reason: 'submodule checked-out commit differs from the committed gitlink (pointer bump not committed on the root branch)',
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
} catch { /* submodule status is best-effort */ }
|
|
1807
|
+
|
|
1808
|
+
return {
|
|
1809
|
+
status: 'failed',
|
|
1810
|
+
hasEffectiveDiff: false,
|
|
1811
|
+
baseHead,
|
|
1812
|
+
branchHead,
|
|
1813
|
+
...(submoduleHints.length ? { submoduleHints } : {}),
|
|
1814
|
+
durationMs: Date.now() - startedAt,
|
|
1815
|
+
};
|
|
1816
|
+
} catch (e: any) {
|
|
1817
|
+
// On error, do NOT block the merge — fail open so a probe failure can't wedge refine.
|
|
1818
|
+
return {
|
|
1819
|
+
status: 'skipped',
|
|
1820
|
+
hasEffectiveDiff: true,
|
|
1821
|
+
baseHead,
|
|
1822
|
+
branchHead,
|
|
1823
|
+
durationMs: Date.now() - startedAt,
|
|
1824
|
+
error: e?.message || String(e),
|
|
1825
|
+
stdout: truncateValidationOutput(e?.stdout),
|
|
1826
|
+
stderr: truncateValidationOutput(e?.stderr),
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1633
1831
|
function buildPatchEquivalenceSubmoduleConflictHint(
|
|
1634
1832
|
repoRoot: string,
|
|
1635
1833
|
baseHead: string,
|
|
@@ -1695,6 +1893,230 @@ function readTreeObject(repoRoot: string, ref: string, path: string): string | u
|
|
|
1695
1893
|
}
|
|
1696
1894
|
}
|
|
1697
1895
|
|
|
1896
|
+
/**
|
|
1897
|
+
* Resolve the absolute path to the repo's real git directory. In a linked
|
|
1898
|
+
* worktree, `.git` is a file pointing elsewhere, so we cannot assume a `.git`
|
|
1899
|
+
* subdirectory exists — a temporary index file must live in the actual git dir.
|
|
1900
|
+
*/
|
|
1901
|
+
function resolveGitDir(repoRoot: string): string {
|
|
1902
|
+
const out = execFileSync('git', ['rev-parse', '--absolute-git-dir'], {
|
|
1903
|
+
cwd: repoRoot,
|
|
1904
|
+
encoding: 'utf8',
|
|
1905
|
+
maxBuffer: 1024 * 1024,
|
|
1906
|
+
}).trim();
|
|
1907
|
+
return out;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
/**
|
|
1911
|
+
* Result of evaluating whether a `git merge-tree --write-tree` submodule
|
|
1912
|
+
* conflict is in fact a trivial gitlink fast-forward that should pass the
|
|
1913
|
+
* patch-equivalence gate.
|
|
1914
|
+
*
|
|
1915
|
+
* `git merge-tree` (and `git merge` with the default recursive strategy)
|
|
1916
|
+
* refuses to 3-way merge gitlinks unless the case is "trivial" — and it
|
|
1917
|
+
* treats *any* gitlink that differs across merge-base/base/branch as
|
|
1918
|
+
* non-trivial, even when the branch-side commit is a strict descendant of the
|
|
1919
|
+
* base-side commit (i.e. a real fast-forward). Refinery only ever wants to
|
|
1920
|
+
* accept the branch's recorded gitlink, so a fast-forwardable bump is safe to
|
|
1921
|
+
* resolve to the branch side without any conflict.
|
|
1922
|
+
*/
|
|
1923
|
+
type GitlinkTrivialFastForwardEvaluation = {
|
|
1924
|
+
/** True only when the merge-tree conflict is *fully* explained by trivial-ff gitlinks. */
|
|
1925
|
+
trivial: boolean;
|
|
1926
|
+
/** Why the evaluation declined to treat the conflict as trivial (set when trivial=false). */
|
|
1927
|
+
reason?: string;
|
|
1928
|
+
/** Per-path detail for the changed gitlinks that were inspected. */
|
|
1929
|
+
gitlinks: Array<{
|
|
1930
|
+
path: string;
|
|
1931
|
+
baseCommit?: string;
|
|
1932
|
+
branchCommit?: string;
|
|
1933
|
+
fastForward: boolean;
|
|
1934
|
+
}>;
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Check, inside a submodule repo, whether `baseCommit` is an ancestor of
|
|
1939
|
+
* `branchCommit` (i.e. advancing the gitlink from base→branch is a pure
|
|
1940
|
+
* fast-forward). Returns false on any error or when either commit is missing
|
|
1941
|
+
* locally — safety first, ambiguity stays "not a fast-forward".
|
|
1942
|
+
*/
|
|
1943
|
+
function isSubmoduleFastForward(submoduleRepoPath: string, baseCommit: string, branchCommit: string): boolean {
|
|
1944
|
+
if (!baseCommit || !branchCommit) return false;
|
|
1945
|
+
if (baseCommit === branchCommit) return true;
|
|
1946
|
+
try {
|
|
1947
|
+
if (!fs.existsSync(submoduleRepoPath)) return false;
|
|
1948
|
+
// Both commits must exist locally for the ancestry check to be meaningful.
|
|
1949
|
+
execFileSync('git', ['cat-file', '-e', `${baseCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
|
|
1950
|
+
execFileSync('git', ['cat-file', '-e', `${branchCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
|
|
1951
|
+
// exit 0 ⇒ baseCommit is an ancestor of branchCommit ⇒ branch fast-forwards base.
|
|
1952
|
+
execFileSync('git', ['merge-base', '--is-ancestor', baseCommit, branchCommit], { cwd: submoduleRepoPath, stdio: 'ignore' });
|
|
1953
|
+
return true;
|
|
1954
|
+
} catch {
|
|
1955
|
+
return false;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/**
|
|
1960
|
+
* Read the set of paths that differ between two refs, tagging whether each is a
|
|
1961
|
+
* gitlink (submodule, mode 160000) on either side. Returns one entry per
|
|
1962
|
+
* changed path. Empty on error.
|
|
1963
|
+
*/
|
|
1964
|
+
function readChangedPathKinds(repoRoot: string, fromRef: string, toRef: string): Array<{ path: string; isGitlink: boolean }> {
|
|
1965
|
+
try {
|
|
1966
|
+
const output = execFileSync('git', ['diff', '--raw', '--no-abbrev', fromRef, toRef], {
|
|
1967
|
+
cwd: repoRoot,
|
|
1968
|
+
encoding: 'utf8',
|
|
1969
|
+
maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
|
|
1970
|
+
});
|
|
1971
|
+
const result: Array<{ path: string; isGitlink: boolean }> = [];
|
|
1972
|
+
const seen = new Set<string>();
|
|
1973
|
+
for (const line of output.split('\n')) {
|
|
1974
|
+
if (!line.trim()) continue;
|
|
1975
|
+
const metaAndPath = line.split('\t');
|
|
1976
|
+
const meta = metaAndPath[0] || '';
|
|
1977
|
+
const path = metaAndPath[metaAndPath.length - 1]?.trim();
|
|
1978
|
+
if (!path || seen.has(path)) continue;
|
|
1979
|
+
seen.add(path);
|
|
1980
|
+
const parts = meta.split(/\s+/);
|
|
1981
|
+
const isGitlink = !!(parts[0]?.includes('160000') || parts[1]?.includes('160000'));
|
|
1982
|
+
result.push({ path, isGitlink });
|
|
1983
|
+
}
|
|
1984
|
+
return result;
|
|
1985
|
+
} catch {
|
|
1986
|
+
return [];
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* Decide whether a merge-tree submodule conflict between base and branch is a
|
|
1992
|
+
* trivial gitlink fast-forward (and nothing else).
|
|
1993
|
+
*
|
|
1994
|
+
* The conflict is treated as trivial ONLY when:
|
|
1995
|
+
* 1. at least one changed gitlink exists,
|
|
1996
|
+
* 2. every changed gitlink fast-forwards (base-commit is an ancestor of the
|
|
1997
|
+
* branch-commit inside that submodule's repo), and
|
|
1998
|
+
* 3. the *only* paths that changed on both sides of the merge (i.e. the paths
|
|
1999
|
+
* that could possibly produce a 3-way conflict — the intersection of
|
|
2000
|
+
* mergeBase→base and mergeBase→branch changes) are gitlinks. Any
|
|
2001
|
+
* overlapping non-gitlink path means a genuine content conflict could be
|
|
2002
|
+
* hiding behind the submodule failure, so we keep the block.
|
|
2003
|
+
*
|
|
2004
|
+
* If any of these fail, the conflict is left as a genuine block. This never
|
|
2005
|
+
* passes a regular-file conflict or a diverged (non-ff) gitlink.
|
|
2006
|
+
*/
|
|
2007
|
+
export function evaluateGitlinkTrivialFastForward(
|
|
2008
|
+
repoRoot: string,
|
|
2009
|
+
baseHead: string,
|
|
2010
|
+
branchHead: string,
|
|
2011
|
+
): GitlinkTrivialFastForwardEvaluation {
|
|
2012
|
+
const changedGitlinks = readChangedGitlinkPaths(repoRoot, baseHead, branchHead).map(path => {
|
|
2013
|
+
const baseCommit = readTreeObject(repoRoot, baseHead, path);
|
|
2014
|
+
const branchCommit = readTreeObject(repoRoot, branchHead, path);
|
|
2015
|
+
const submoduleRepoPath = pathResolve(repoRoot, path);
|
|
2016
|
+
const fastForward = !!baseCommit && !!branchCommit
|
|
2017
|
+
&& isSubmoduleFastForward(submoduleRepoPath, baseCommit, branchCommit);
|
|
2018
|
+
return { path, baseCommit, branchCommit, fastForward };
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
if (changedGitlinks.length === 0) {
|
|
2022
|
+
return { trivial: false, reason: 'no_changed_gitlinks', gitlinks: changedGitlinks };
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
const nonFastForward = changedGitlinks.filter(entry => !entry.fastForward);
|
|
2026
|
+
if (nonFastForward.length > 0) {
|
|
2027
|
+
return {
|
|
2028
|
+
trivial: false,
|
|
2029
|
+
reason: `diverged_gitlinks:${nonFastForward.map(entry => entry.path).join(',')}`,
|
|
2030
|
+
gitlinks: changedGitlinks,
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// Prove there is no *other* conflict (regular files, or a gitlink that
|
|
2035
|
+
// diverged on both sides). A 3-way merge can only conflict on a path that
|
|
2036
|
+
// changed on BOTH sides relative to the merge-base. Compute that overlap and
|
|
2037
|
+
// require every overlapping path to be a gitlink — non-gitlink overlap means
|
|
2038
|
+
// a genuine content conflict that must stay blocked.
|
|
2039
|
+
let mergeBase = '';
|
|
2040
|
+
try {
|
|
2041
|
+
mergeBase = execFileSync('git', ['merge-base', baseHead, branchHead], {
|
|
2042
|
+
cwd: repoRoot,
|
|
2043
|
+
encoding: 'utf8',
|
|
2044
|
+
maxBuffer: 1024 * 1024,
|
|
2045
|
+
}).trim();
|
|
2046
|
+
} catch {
|
|
2047
|
+
return { trivial: false, reason: 'merge_base_unresolved', gitlinks: changedGitlinks };
|
|
2048
|
+
}
|
|
2049
|
+
if (!mergeBase) {
|
|
2050
|
+
return { trivial: false, reason: 'merge_base_unresolved', gitlinks: changedGitlinks };
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
const baseSideChanges = readChangedPathKinds(repoRoot, mergeBase, baseHead);
|
|
2054
|
+
const branchSideChanges = readChangedPathKinds(repoRoot, mergeBase, branchHead);
|
|
2055
|
+
const baseChangedPaths = new Map(baseSideChanges.map(entry => [entry.path, entry]));
|
|
2056
|
+
// Overlapping paths = candidates for a real 3-way conflict.
|
|
2057
|
+
const overlapping = branchSideChanges.filter(entry => baseChangedPaths.has(entry.path));
|
|
2058
|
+
const nonGitlinkOverlap = overlapping.filter(entry => {
|
|
2059
|
+
const baseEntry = baseChangedPaths.get(entry.path);
|
|
2060
|
+
return !(entry.isGitlink && baseEntry?.isGitlink);
|
|
2061
|
+
});
|
|
2062
|
+
if (nonGitlinkOverlap.length > 0) {
|
|
2063
|
+
return {
|
|
2064
|
+
trivial: false,
|
|
2065
|
+
reason: `non_gitlink_overlap:${nonGitlinkOverlap.map(entry => entry.path).join(',')}`,
|
|
2066
|
+
gitlinks: changedGitlinks,
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
return { trivial: true, gitlinks: changedGitlinks };
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* Synthesize the merge result for a trivial gitlink fast-forward: take
|
|
2075
|
+
* `baseHead`'s tree and overlay each changed gitlink's branch-side commit.
|
|
2076
|
+
* This mirrors what `git merge-tree` would have produced had it not bailed on
|
|
2077
|
+
* the submodule recursion limitation. Returns the tree SHA, or undefined on
|
|
2078
|
+
* failure. Caller must have already proven (via evaluateGitlinkTrivialFastForward)
|
|
2079
|
+
* that every changed gitlink fast-forwards and no other path conflicts.
|
|
2080
|
+
*/
|
|
2081
|
+
function synthesizeTrivialFastForwardMergeTree(
|
|
2082
|
+
repoRoot: string,
|
|
2083
|
+
baseHead: string,
|
|
2084
|
+
branchHead: string,
|
|
2085
|
+
gitlinks: Array<{ path: string; branchCommit?: string }>,
|
|
2086
|
+
): string | undefined {
|
|
2087
|
+
try {
|
|
2088
|
+
const baseTree = execFileSync('git', ['rev-parse', `${baseHead}^{tree}`], {
|
|
2089
|
+
cwd: repoRoot,
|
|
2090
|
+
encoding: 'utf8',
|
|
2091
|
+
maxBuffer: 1024 * 1024,
|
|
2092
|
+
}).trim();
|
|
2093
|
+
if (!baseTree) return undefined;
|
|
2094
|
+
const updates = gitlinks
|
|
2095
|
+
.filter(entry => entry.branchCommit)
|
|
2096
|
+
.map(entry => `160000 commit ${entry.branchCommit}\t${entry.path}`)
|
|
2097
|
+
.join('\n');
|
|
2098
|
+
if (!updates) return baseTree;
|
|
2099
|
+
const tmpIndex = pathJoin(resolveGitDir(repoRoot), `adhdev-refine-ff-${baseHead.slice(0, 12)}-${branchHead.slice(0, 12)}.index`);
|
|
2100
|
+
const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
|
|
2101
|
+
try {
|
|
2102
|
+
execFileSync('git', ['read-tree', baseTree], { cwd: repoRoot, env, stdio: 'ignore' });
|
|
2103
|
+
execFileSync('git', ['update-index', '--index-info'], {
|
|
2104
|
+
cwd: repoRoot,
|
|
2105
|
+
env,
|
|
2106
|
+
input: `${updates}\n`,
|
|
2107
|
+
encoding: 'utf8',
|
|
2108
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
2109
|
+
});
|
|
2110
|
+
const newTree = execFileSync('git', ['write-tree'], { cwd: repoRoot, env, encoding: 'utf8' }).trim();
|
|
2111
|
+
return newTree || undefined;
|
|
2112
|
+
} finally {
|
|
2113
|
+
try { fs.rmSync(tmpIndex, { force: true }); } catch { /* ignore */ }
|
|
2114
|
+
}
|
|
2115
|
+
} catch {
|
|
2116
|
+
return undefined;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
|
|
1698
2120
|
async function alignRefinerySubmodulesAfterMerge(
|
|
1699
2121
|
repoRoot: string,
|
|
1700
2122
|
previousBaseHead: string,
|
|
@@ -2520,6 +2942,10 @@ export class DaemonCommandRouter {
|
|
|
2520
2942
|
private runningRefineJobs = new Map<string, MeshRefineJobHandle>();
|
|
2521
2943
|
/** Terminal async Refinery jobs preserve a clear answer after the worktree node has been removed. */
|
|
2522
2944
|
private terminalRefineJobs = new Map<string, MeshRefineTerminalJob>();
|
|
2945
|
+
/** In-memory async batch Refinery jobs keyed by meshId (one batch convergence per mesh at a time). */
|
|
2946
|
+
private runningRefineBatchJobs = new Map<string, MeshRefineBatchJobHandle>();
|
|
2947
|
+
/** Terminal async batch Refinery jobs preserve the last batch outcome for late readers. */
|
|
2948
|
+
private terminalRefineBatchJobs = new Map<string, MeshRefineBatchTerminalJob>();
|
|
2523
2949
|
|
|
2524
2950
|
constructor(deps: CommandRouterDeps) {
|
|
2525
2951
|
this.deps = deps;
|
|
@@ -3935,6 +4361,53 @@ export class DaemonCommandRouter {
|
|
|
3935
4361
|
};
|
|
3936
4362
|
}
|
|
3937
4363
|
|
|
4364
|
+
// No-op guard: block a silent no-op merge where the root tree is identical to base.
|
|
4365
|
+
// This catches the trap where a submodule has commits but the root branch never
|
|
4366
|
+
// committed the gitlink (oss-pointer) bump — merging would report success while the
|
|
4367
|
+
// real change never lands on main. A committed gitlink bump shows up in the root
|
|
4368
|
+
// diff, so legitimate oss-pointer refines pass through untouched.
|
|
4369
|
+
const effectiveDiffStarted = Date.now();
|
|
4370
|
+
const effectiveDiff = await runMeshRefineEffectiveDiffGate(repoRoot, baseHead, branchHead);
|
|
4371
|
+
recordMeshRefineStage(refineStages, 'effective_diff', effectiveDiff.status, effectiveDiffStarted, {
|
|
4372
|
+
hasEffectiveDiff: effectiveDiff.hasEffectiveDiff,
|
|
4373
|
+
changedPaths: effectiveDiff.changedPaths,
|
|
4374
|
+
submoduleHints: effectiveDiff.submoduleHints,
|
|
4375
|
+
...(effectiveDiff.error ? { error: effectiveDiff.error } : {}),
|
|
4376
|
+
});
|
|
4377
|
+
if (effectiveDiff.status === 'failed' && !effectiveDiff.hasEffectiveDiff) {
|
|
4378
|
+
const hintLines = (effectiveDiff.submoduleHints || []).map(h => ` - ${h.path}: ${h.reason}`);
|
|
4379
|
+
const message = [
|
|
4380
|
+
`Refinery no-op guard: branch '${branch}' has no effective root-tree diff against '${baseBranch}' (${baseHead.slice(0, 12)}); nothing would merge.`,
|
|
4381
|
+
'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.',
|
|
4382
|
+
hintLines.length ? `Submodules with uncommitted pointer bumps:\n${hintLines.join('\n')}` : '',
|
|
4383
|
+
`Fix: commit the submodule pointer bump on '${branch}' (git add <submodule-path> && git commit), then re-run refine.`,
|
|
4384
|
+
].filter(Boolean).join('\n');
|
|
4385
|
+
return {
|
|
4386
|
+
success: false,
|
|
4387
|
+
code: 'no_effective_diff',
|
|
4388
|
+
convergenceStatus: 'blocked_review',
|
|
4389
|
+
error: message,
|
|
4390
|
+
branch,
|
|
4391
|
+
into: baseBranch,
|
|
4392
|
+
validationSummary,
|
|
4393
|
+
patchEquivalence,
|
|
4394
|
+
effectiveDiff,
|
|
4395
|
+
refineStages,
|
|
4396
|
+
finalBranchConvergenceState: {
|
|
4397
|
+
branch,
|
|
4398
|
+
baseBranch,
|
|
4399
|
+
merged: false,
|
|
4400
|
+
removed: false,
|
|
4401
|
+
validation: 'passed',
|
|
4402
|
+
patchEquivalence: 'passed',
|
|
4403
|
+
effectiveDiff: 'no_effective_diff',
|
|
4404
|
+
status: 'blocked_review',
|
|
4405
|
+
reason: 'no_effective_diff',
|
|
4406
|
+
...(effectiveDiff.submoduleHints?.length ? { submoduleHints: effectiveDiff.submoduleHints } : {}),
|
|
4407
|
+
},
|
|
4408
|
+
};
|
|
4409
|
+
}
|
|
4410
|
+
|
|
3938
4411
|
let mergeResult: Record<string, unknown> | undefined;
|
|
3939
4412
|
const mergeStarted = Date.now();
|
|
3940
4413
|
try {
|
|
@@ -4294,10 +4767,24 @@ export class DaemonCommandRouter {
|
|
|
4294
4767
|
};
|
|
4295
4768
|
}
|
|
4296
4769
|
|
|
4297
|
-
// Execute: refine each node in order
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4770
|
+
// Execute: refine each node in order via the shared convergence core.
|
|
4771
|
+
return this.runMeshRefineBatchConvergence(meshId, orderedNodes, ordering, args);
|
|
4772
|
+
}
|
|
4773
|
+
|
|
4774
|
+
/**
|
|
4775
|
+
* Convergence core shared by the synchronous batch entry and the async batch job.
|
|
4776
|
+
* Refines each node in order: the per-node refine pipeline fetches origin/<base>
|
|
4777
|
+
* fresh, so each merged sibling advances the base before the next node's auto-rebase
|
|
4778
|
+
* + patch-equivalence re-check. A blocked/failed node is isolated; the batch
|
|
4779
|
+
* continues with the remaining nodes. Does NOT touch the per-node merge logic — it
|
|
4780
|
+
* only sequences calls to executeMeshRefineNodeSynchronously and aggregates outcomes.
|
|
4781
|
+
*/
|
|
4782
|
+
private async runMeshRefineBatchConvergence(
|
|
4783
|
+
meshId: string,
|
|
4784
|
+
orderedNodes: any[],
|
|
4785
|
+
ordering: { order: string[]; rationale?: unknown },
|
|
4786
|
+
args: any,
|
|
4787
|
+
): Promise<CommandRouterResult> {
|
|
4301
4788
|
type BatchNodeOutcome = {
|
|
4302
4789
|
nodeId: string;
|
|
4303
4790
|
workspace: string;
|
|
@@ -4372,6 +4859,261 @@ export class DaemonCommandRouter {
|
|
|
4372
4859
|
};
|
|
4373
4860
|
}
|
|
4374
4861
|
|
|
4862
|
+
private buildRefineBatchJobKey(meshId: string): string {
|
|
4863
|
+
return `${meshId}::batch`;
|
|
4864
|
+
}
|
|
4865
|
+
|
|
4866
|
+
private buildRefineBatchJobHandle(args: {
|
|
4867
|
+
meshId: string;
|
|
4868
|
+
nodeIds: string[];
|
|
4869
|
+
order: string[];
|
|
4870
|
+
status?: MeshRefineBatchJobStatus;
|
|
4871
|
+
startedAt?: string;
|
|
4872
|
+
completedAt?: string;
|
|
4873
|
+
jobId?: string;
|
|
4874
|
+
interactionId?: string;
|
|
4875
|
+
coordinatorDaemonId?: string;
|
|
4876
|
+
}): MeshRefineBatchJobHandle {
|
|
4877
|
+
return {
|
|
4878
|
+
success: true,
|
|
4879
|
+
async: true,
|
|
4880
|
+
batch: true,
|
|
4881
|
+
status: args.status || 'accepted',
|
|
4882
|
+
jobId: args.jobId || `refine_batch_${createInteractionId()}`,
|
|
4883
|
+
interactionId: args.interactionId || createInteractionId(),
|
|
4884
|
+
meshId: args.meshId,
|
|
4885
|
+
batchLabel: `batch:${args.nodeIds.length} node${args.nodeIds.length === 1 ? '' : 's'}`,
|
|
4886
|
+
nodeIds: args.nodeIds,
|
|
4887
|
+
nodeCount: args.nodeIds.length,
|
|
4888
|
+
order: args.order,
|
|
4889
|
+
startedAt: args.startedAt || new Date().toISOString(),
|
|
4890
|
+
...(args.completedAt ? { completedAt: args.completedAt } : {}),
|
|
4891
|
+
...(args.coordinatorDaemonId ? { targetCoordinatorDaemonId: args.coordinatorDaemonId } : {}),
|
|
4892
|
+
eventDelivery: { pendingEvents: true, ledger: true },
|
|
4893
|
+
evidence: {
|
|
4894
|
+
pendingEventsCommand: 'get_pending_mesh_events',
|
|
4895
|
+
ledgerCommand: 'get_mesh_ledger_slice',
|
|
4896
|
+
taskHistoryKind: args.status === 'completed' ? 'task_completed' : args.status === 'failed' ? 'task_failed' : 'task_dispatched',
|
|
4897
|
+
},
|
|
4898
|
+
};
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
/**
|
|
4902
|
+
* Emit a batch Refinery terminal/accepted event through the SAME pending-event +
|
|
4903
|
+
* forward mechanism single-node refine uses (queueRefineJobEvent), so the
|
|
4904
|
+
* coordinator's existing refine:accepted/completed/failed handling and message
|
|
4905
|
+
* renderer apply unchanged. The aggregate per-node results ride along in `result`.
|
|
4906
|
+
*/
|
|
4907
|
+
private queueRefineBatchJobEvent(
|
|
4908
|
+
event: 'refine:accepted' | 'refine:completed' | 'refine:failed',
|
|
4909
|
+
handle: MeshRefineBatchJobHandle,
|
|
4910
|
+
result?: Record<string, unknown>,
|
|
4911
|
+
): void {
|
|
4912
|
+
const metadataEvent = {
|
|
4913
|
+
source: 'refine_mesh_node_async_job',
|
|
4914
|
+
batch: true,
|
|
4915
|
+
jobId: handle.jobId,
|
|
4916
|
+
interactionId: handle.interactionId,
|
|
4917
|
+
meshId: handle.meshId,
|
|
4918
|
+
nodeId: handle.batchLabel,
|
|
4919
|
+
nodeIds: handle.nodeIds,
|
|
4920
|
+
workspace: undefined,
|
|
4921
|
+
status: handle.status,
|
|
4922
|
+
startedAt: handle.startedAt,
|
|
4923
|
+
completedAt: handle.completedAt,
|
|
4924
|
+
order: handle.order,
|
|
4925
|
+
...(result ? { result } : {}),
|
|
4926
|
+
};
|
|
4927
|
+
const eventPayload = {
|
|
4928
|
+
event,
|
|
4929
|
+
meshId: handle.meshId,
|
|
4930
|
+
nodeLabel: handle.batchLabel,
|
|
4931
|
+
nodeId: handle.batchLabel,
|
|
4932
|
+
metadataEvent,
|
|
4933
|
+
queuedAt: Date.now(),
|
|
4934
|
+
...(handle.targetCoordinatorDaemonId ? { targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId } : {}),
|
|
4935
|
+
};
|
|
4936
|
+
if (typeof this.deps.instanceManager?.getByCategory === 'function') {
|
|
4937
|
+
const forwarded = handleMeshForwardEvent(
|
|
4938
|
+
{ instanceManager: this.deps.instanceManager } as any,
|
|
4939
|
+
{
|
|
4940
|
+
event,
|
|
4941
|
+
meshId: handle.meshId,
|
|
4942
|
+
nodeId: handle.batchLabel,
|
|
4943
|
+
jobId: handle.jobId,
|
|
4944
|
+
interactionId: handle.interactionId,
|
|
4945
|
+
status: handle.status,
|
|
4946
|
+
startedAt: handle.startedAt,
|
|
4947
|
+
completedAt: handle.completedAt,
|
|
4948
|
+
...(result ? { result } : {}),
|
|
4949
|
+
},
|
|
4950
|
+
);
|
|
4951
|
+
if (forwarded?.success === true) return;
|
|
4952
|
+
LOG.warn('Mesh', `[Refinery] Failed to forward async refine batch event ${event}: ${forwarded?.error || 'unknown error'}`);
|
|
4953
|
+
}
|
|
4954
|
+
queuePendingMeshCoordinatorEvent(eventPayload);
|
|
4955
|
+
}
|
|
4956
|
+
|
|
4957
|
+
private async appendRefineBatchJobLedger(
|
|
4958
|
+
kind: 'task_dispatched' | 'task_completed' | 'task_failed',
|
|
4959
|
+
handle: MeshRefineBatchJobHandle,
|
|
4960
|
+
result?: Record<string, unknown>,
|
|
4961
|
+
): Promise<void> {
|
|
4962
|
+
try {
|
|
4963
|
+
const { appendLedgerEntry } = await import('../mesh/mesh-ledger.js');
|
|
4964
|
+
appendLedgerEntry(handle.meshId, {
|
|
4965
|
+
kind,
|
|
4966
|
+
nodeId: handle.batchLabel,
|
|
4967
|
+
payload: {
|
|
4968
|
+
source: 'refine_mesh_node_async_job',
|
|
4969
|
+
refineJob: {
|
|
4970
|
+
batch: true,
|
|
4971
|
+
jobId: handle.jobId,
|
|
4972
|
+
interactionId: handle.interactionId,
|
|
4973
|
+
status: handle.status,
|
|
4974
|
+
meshId: handle.meshId,
|
|
4975
|
+
nodeIds: handle.nodeIds,
|
|
4976
|
+
order: handle.order,
|
|
4977
|
+
targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId,
|
|
4978
|
+
startedAt: handle.startedAt,
|
|
4979
|
+
completedAt: handle.completedAt,
|
|
4980
|
+
},
|
|
4981
|
+
async: true,
|
|
4982
|
+
batch: true,
|
|
4983
|
+
...(result ? {
|
|
4984
|
+
success: result.success === true,
|
|
4985
|
+
result,
|
|
4986
|
+
} : {}),
|
|
4987
|
+
},
|
|
4988
|
+
});
|
|
4989
|
+
} catch (e: any) {
|
|
4990
|
+
LOG.warn('Mesh', `[Refinery] Failed to append async refine batch ledger entry: ${e?.message || e}`);
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4993
|
+
|
|
4994
|
+
private async finishMeshRefineBatchJob(
|
|
4995
|
+
handle: MeshRefineBatchJobHandle,
|
|
4996
|
+
orderedNodes: any[],
|
|
4997
|
+
ordering: { order: string[]; rationale?: unknown },
|
|
4998
|
+
args: any,
|
|
4999
|
+
): Promise<void> {
|
|
5000
|
+
const key = this.buildRefineBatchJobKey(handle.meshId);
|
|
5001
|
+
let result: Record<string, unknown>;
|
|
5002
|
+
try {
|
|
5003
|
+
result = await this.runMeshRefineBatchConvergence(handle.meshId, orderedNodes, ordering, args) as Record<string, unknown>;
|
|
5004
|
+
} catch (e: any) {
|
|
5005
|
+
result = { success: false, error: e?.message || String(e), batch: true };
|
|
5006
|
+
}
|
|
5007
|
+
const completedAt = new Date().toISOString();
|
|
5008
|
+
|
|
5009
|
+
// The batch as a whole "completed" only when every node converged (no blocked /
|
|
5010
|
+
// not_mergeable). A partial batch is reported as a terminal failure so the
|
|
5011
|
+
// coordinator inspects the per-node blockers rather than assuming a clean merge.
|
|
5012
|
+
const summary = (result.summary && typeof result.summary === 'object') ? result.summary as Record<string, number> : undefined;
|
|
5013
|
+
const allConverged = result.allConverged === true;
|
|
5014
|
+
const isTerminalSuccess = result.success === true && allConverged;
|
|
5015
|
+
|
|
5016
|
+
const nextStep = typeof result.nextStep === 'string' && result.nextStep
|
|
5017
|
+
? result.nextStep
|
|
5018
|
+
: isTerminalSuccess
|
|
5019
|
+
? 'All batched nodes converged onto base. Continue from the updated mesh state.'
|
|
5020
|
+
: '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.';
|
|
5021
|
+
const normalizedResult = {
|
|
5022
|
+
...result,
|
|
5023
|
+
batch: true,
|
|
5024
|
+
nextStep,
|
|
5025
|
+
...(summary ? {
|
|
5026
|
+
convergenceStatus: allConverged ? 'all_converged' : 'partial',
|
|
5027
|
+
} : {}),
|
|
5028
|
+
};
|
|
5029
|
+
|
|
5030
|
+
const terminalHandle = this.buildRefineBatchJobHandle({
|
|
5031
|
+
meshId: handle.meshId,
|
|
5032
|
+
nodeIds: handle.nodeIds,
|
|
5033
|
+
order: handle.order,
|
|
5034
|
+
status: isTerminalSuccess ? 'completed' : 'failed',
|
|
5035
|
+
startedAt: handle.startedAt,
|
|
5036
|
+
completedAt,
|
|
5037
|
+
jobId: handle.jobId,
|
|
5038
|
+
interactionId: handle.interactionId,
|
|
5039
|
+
coordinatorDaemonId: handle.targetCoordinatorDaemonId,
|
|
5040
|
+
});
|
|
5041
|
+
const terminal: MeshRefineBatchTerminalJob = { ...terminalHandle, result: normalizedResult };
|
|
5042
|
+
this.terminalRefineBatchJobs.set(key, terminal);
|
|
5043
|
+
this.runningRefineBatchJobs.delete(key);
|
|
5044
|
+
this.invalidateAggregateMeshStatus(handle.meshId);
|
|
5045
|
+
await this.appendRefineBatchJobLedger(isTerminalSuccess ? 'task_completed' : 'task_failed', terminalHandle, normalizedResult);
|
|
5046
|
+
this.queueRefineBatchJobEvent(isTerminalSuccess ? 'refine:completed' : 'refine:failed', terminalHandle, normalizedResult);
|
|
5047
|
+
}
|
|
5048
|
+
|
|
5049
|
+
/**
|
|
5050
|
+
* Async entry for the batch Refinery execute path. Mirrors startMeshRefineJob:
|
|
5051
|
+
* resolves the plan synchronously (so target/ordering errors and the dry-run shape
|
|
5052
|
+
* stay synchronous), then for execute=true registers an in-flight batch job, returns
|
|
5053
|
+
* {async:true, status:'accepted', batch:true, ...plan} immediately, and runs the
|
|
5054
|
+
* convergence loop in the background — emitting the same terminal refine event.
|
|
5055
|
+
* Idempotent: a batch already in flight for this mesh returns the running handle
|
|
5056
|
+
* with duplicate:true rather than spawning a second background job.
|
|
5057
|
+
*/
|
|
5058
|
+
private async startMeshRefineBatchJob(meshId: string, requestedNodeIds: string[] | undefined, args: any): Promise<CommandRouterResult> {
|
|
5059
|
+
// Resolve the plan up-front. For dry-run this returns the synchronous plan; for
|
|
5060
|
+
// execute it returns the same plan shape but we hand convergence to the bg job.
|
|
5061
|
+
const plan = await this.batchRefineMeshNodes(meshId, requestedNodeIds, { ...args, dryRun: true, execute: false });
|
|
5062
|
+
const planRecord = plan as Record<string, unknown>;
|
|
5063
|
+
if (planRecord.success !== true) return plan;
|
|
5064
|
+
|
|
5065
|
+
// If the caller actually asked for a dry-run, return the plan as-is (sync).
|
|
5066
|
+
if (args?.dryRun === true && args?.execute !== true) return plan;
|
|
5067
|
+
|
|
5068
|
+
const order = Array.isArray(planRecord.order) ? (planRecord.order as unknown[]).filter((v): v is string => typeof v === 'string') : [];
|
|
5069
|
+
const nodeIds = order.slice();
|
|
5070
|
+
if (nodeIds.length === 0) {
|
|
5071
|
+
// No convergeable nodes — nothing to dispatch; return the empty plan synchronously.
|
|
5072
|
+
return { ...planRecord, success: true, batch: true, dryRun: false, async: false };
|
|
5073
|
+
}
|
|
5074
|
+
|
|
5075
|
+
const key = this.buildRefineBatchJobKey(meshId);
|
|
5076
|
+
const running = this.runningRefineBatchJobs.get(key);
|
|
5077
|
+
if (running) return { ...running, duplicate: true };
|
|
5078
|
+
|
|
5079
|
+
// Re-resolve the ordered node objects against current membership so the bg job
|
|
5080
|
+
// refines real nodes (the plan only carries ids). preferInline matches refine_mesh_node.
|
|
5081
|
+
const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
5082
|
+
const mesh = meshRecord?.mesh;
|
|
5083
|
+
const allNodes: any[] = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
|
|
5084
|
+
const orderedNodes = nodeIds
|
|
5085
|
+
.map(id => allNodes.find(n => n.id === id || n.nodeId === id))
|
|
5086
|
+
.filter((n): n is any => !!n);
|
|
5087
|
+
if (orderedNodes.length === 0) {
|
|
5088
|
+
return { success: false, error: 'Batch nodes no longer resolvable in mesh', batch: true };
|
|
5089
|
+
}
|
|
5090
|
+
const ordering = {
|
|
5091
|
+
order,
|
|
5092
|
+
rationale: planRecord.orderingRationale,
|
|
5093
|
+
};
|
|
5094
|
+
|
|
5095
|
+
const coordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
|
|
5096
|
+
? args.coordinatorDaemonId.trim()
|
|
5097
|
+
: (this.deps.statusInstanceId || undefined);
|
|
5098
|
+
const handle = this.buildRefineBatchJobHandle({ meshId, nodeIds, order, coordinatorDaemonId });
|
|
5099
|
+
this.runningRefineBatchJobs.set(key, handle);
|
|
5100
|
+
await this.appendRefineBatchJobLedger('task_dispatched', handle);
|
|
5101
|
+
this.queueRefineBatchJobEvent('refine:accepted', handle);
|
|
5102
|
+
|
|
5103
|
+
setImmediate(() => {
|
|
5104
|
+
void this.finishMeshRefineBatchJob(handle, orderedNodes, ordering, args);
|
|
5105
|
+
});
|
|
5106
|
+
|
|
5107
|
+
// Return the accepted handle plus the plan so the coordinator sees the target set.
|
|
5108
|
+
return {
|
|
5109
|
+
...handle,
|
|
5110
|
+
order,
|
|
5111
|
+
orderingRationale: planRecord.orderingRationale,
|
|
5112
|
+
plan: planRecord.plan,
|
|
5113
|
+
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.',
|
|
5114
|
+
};
|
|
5115
|
+
}
|
|
5116
|
+
|
|
4375
5117
|
private async finishMeshRefineJob(handle: MeshRefineJobHandle, args: any): Promise<void> {
|
|
4376
5118
|
const key = this.buildRefineJobKey(handle.meshId, handle.targetNodeId);
|
|
4377
5119
|
let result: Record<string, unknown>;
|
|
@@ -6196,7 +6938,14 @@ export class DaemonCommandRouter {
|
|
|
6196
6938
|
const requestedNodeIds = Array.isArray(args?.nodeIds)
|
|
6197
6939
|
? (args.nodeIds as unknown[]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0).map(v => v.trim())
|
|
6198
6940
|
: undefined;
|
|
6199
|
-
|
|
6941
|
+
// Dry-run (plan-only) stays synchronous: it does no validation/merge and
|
|
6942
|
+
// returns instantly. Execute goes through the async batch job — immediate
|
|
6943
|
+
// {async:true, status:'accepted'} + background convergence + terminal event,
|
|
6944
|
+
// matching the single-node refine_mesh_node contract so long validation
|
|
6945
|
+
// suites can't time out the IPC and strand the coordinator.
|
|
6946
|
+
const isDryRun = args?.dryRun !== false && args?.execute !== true;
|
|
6947
|
+
if (isDryRun) return this.batchRefineMeshNodes(meshId, requestedNodeIds, args);
|
|
6948
|
+
return this.startMeshRefineBatchJob(meshId, requestedNodeIds, args);
|
|
6200
6949
|
}
|
|
6201
6950
|
|
|
6202
6951
|
case 'remove_mesh_node': {
|