@smartmemory/compose 0.2.57-beta → 0.3.0
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/README.md +2 -2
- package/bin/compose.js +55 -54
- package/contracts/feature-json.schema.json +16 -0
- package/dist/assets/{App-BmhlHOXF.js → App-DJ5xk_Wx.js} +219 -219
- package/dist/assets/{abnfDiagram-VRR7QNED-CMOTYAnt.js → abnfDiagram-VRR7QNED-BPWGdCFx.js} +1 -1
- package/dist/assets/{arc-BJAv_6dL.js → arc-DX5jqmcO.js} +1 -1
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-xa4DLUcp.js → architectureDiagram-ZJ3FMSHR-B6jmNVw7.js} +1 -1
- package/dist/assets/{blockDiagram-677ZJIJ3-BdkDqeQF.js → blockDiagram-677ZJIJ3-DAx2i-sB.js} +1 -1
- package/dist/assets/{c4Diagram-LMCZKHZV-DiNrLIuL.js → c4Diagram-LMCZKHZV-zmsZCplj.js} +1 -1
- package/dist/assets/channel-vsDnTvkh.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BsjtgrSL.js → chunk-2Q5K7J3B-3QSh6sI7.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-DpImULWy.js → chunk-32BRIVSS-BiH5Di_y.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-DTdG5JnR.js → chunk-5VM5RSS4-C0EfjFLd.js} +1 -1
- package/dist/assets/{chunk-EX3LRPZG-Cj1jNuKY.js → chunk-EX3LRPZG-BGyfmVm-.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-DwQ0wwWn.js → chunk-JWPE2WC7-Ba0mrNlg.js} +1 -1
- package/dist/assets/{chunk-MOJQB5TN-DZZa7unc.js → chunk-MOJQB5TN-D3QO9EzH.js} +1 -1
- package/dist/assets/{chunk-RYQCIY6F-CgdjqYQj.js → chunk-RYQCIY6F-D1uvNA6d.js} +1 -1
- package/dist/assets/{chunk-V7JOEXUC-DrPIj6XI.js → chunk-V7JOEXUC-fihlnodT.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-BBUOVC5W.js → chunk-VR4S4FIN-nmRUEo1H.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-BEIiZ6gS.js → chunk-XXDRQBXY-CM693yEg.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-DO-wdSFZ.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-DO-wdSFZ.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-ClFTQEyD.js → cose-bilkent-JH36ORCC-CkacPn8W.js} +1 -1
- package/dist/assets/{cynefin-VYW2F7L2-5Bp7xFO0.js → cynefin-VYW2F7L2-BiHaIptG.js} +1 -1
- package/dist/assets/{cynefinDiagram-TSTJHNR4-C-0QlSda.js → cynefinDiagram-TSTJHNR4-CdKxMmiy.js} +1 -1
- package/dist/assets/{dagre-VKFMJZFB-DLkqsfBy.js → dagre-VKFMJZFB-BQLY5y_W.js} +1 -1
- package/dist/assets/{diagram-FQU43EPY-Cwh_tJ_r.js → diagram-FQU43EPY-D7uMBHvq.js} +1 -1
- package/dist/assets/{diagram-G47NLZAW-CvvAjulf.js → diagram-G47NLZAW-B3Z1cuH7.js} +1 -1
- package/dist/assets/{diagram-NH7WQ7WH-CCoyx8_e.js → diagram-NH7WQ7WH-BZyRD45e.js} +1 -1
- package/dist/assets/{diagram-OA4YK3LP-DfjhKYCp.js → diagram-OA4YK3LP-DcThOTt7.js} +1 -1
- package/dist/assets/{diagram-WEI45ONY-Vjz0ygfB.js → diagram-WEI45ONY-BcgRAkqY.js} +1 -1
- package/dist/assets/{ebnfDiagram-CCIWWBDH-GsA8c6g2.js → ebnfDiagram-CCIWWBDH-CGwfO_xH.js} +1 -1
- package/dist/assets/{erDiagram-Q63AITRT-Cc0lJ9sQ.js → erDiagram-Q63AITRT-pV58-Ncc.js} +1 -1
- package/dist/assets/{flowDiagram-23GEKE2U-C_sotK-k.js → flowDiagram-23GEKE2U-DJ_SqE8h.js} +1 -1
- package/dist/assets/{ganttDiagram-NO4QXBWP-BBtu_WUe.js → ganttDiagram-NO4QXBWP-Dgy0Iyss.js} +1 -1
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-BV_aSB8-.js → gitGraphDiagram-IHSO6WYX-CbiZw9fb.js} +1 -1
- package/dist/assets/{index-CKfOVv2N.js → index-DZTJEk-y.js} +2 -2
- package/dist/assets/{infoDiagram-FWYZ7A6U-DMAvNGtX.js → infoDiagram-FWYZ7A6U-CtsyyEc-.js} +1 -1
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-DfQB91MZ.js → ishikawaDiagram-FXEZZL3T-BJilNFkK.js} +1 -1
- package/dist/assets/{journeyDiagram-5HDEW3XC-Bq4Llm9o.js → journeyDiagram-5HDEW3XC-C2UCMP4t.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-DTXHiDiE.js → kanban-definition-HUTT4EX6-DmLDJBRy.js} +1 -1
- package/dist/assets/{linear-BQSYZcVg.js → linear-C1paCqE7.js} +1 -1
- package/dist/assets/{mindmap-definition-LN4V7U3C-KuH2NIj0.js → mindmap-definition-LN4V7U3C-CX-RxKVn.js} +1 -1
- package/dist/assets/{pegDiagram-2B236MQR-BKoNGQFr.js → pegDiagram-2B236MQR-CTU32H2W.js} +1 -1
- package/dist/assets/{pieDiagram-ENE6RG2P-EpYv162Y.js → pieDiagram-ENE6RG2P-D8L1aYoo.js} +1 -1
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-C9deUjQ2.js → quadrantDiagram-ABIIQ3AL-hQ3bXosy.js} +1 -1
- package/dist/assets/{railroadDiagram-RFXS5EU6-Csqw2hK9.js → railroadDiagram-RFXS5EU6--_vuYcda.js} +1 -1
- package/dist/assets/{requirementDiagram-TGXJPOKE-DCuz3emr.js → requirementDiagram-TGXJPOKE-C6h_m3Az.js} +1 -1
- package/dist/assets/{sankeyDiagram-HTMAVEWB-BTeTL24f.js → sankeyDiagram-HTMAVEWB-Dpc7CfIQ.js} +1 -1
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-Cf1ZkTwO.js → sequenceDiagram-DBY2YBRQ-CK5ZzbvJ.js} +1 -1
- package/dist/assets/{sizeCapture-X5ZJPWSS-D3Lbewzg.js → sizeCapture-X5ZJPWSS-BAxVvM9G.js} +1 -1
- package/dist/assets/{stateDiagram-2N3HPSRC-O8l37yJI.js → stateDiagram-2N3HPSRC-7j33_2PY.js} +1 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-GviN0FpJ.js +1 -0
- package/dist/assets/{swimlanes-5IMT3BWC-BikmTh8b.js → swimlanes-5IMT3BWC-CBG2yOob.js} +2 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-D0wMoxl1.js +8 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-BOxSosG0.js → timeline-definition-FHXFAJF6-BOnZkQvc.js} +1 -1
- package/dist/assets/{vennDiagram-L72KCM5P-BSClOErZ.js → vennDiagram-L72KCM5P-D52NhIfj.js} +1 -1
- package/dist/assets/{wardleyDiagram-EHGQE667-DHFRjOK0.js → wardleyDiagram-EHGQE667-YoPY2ZU-.js} +1 -1
- package/dist/assets/{xychartDiagram-FW5EYKEG-Dh1v4x-O.js → xychartDiagram-FW5EYKEG-BanNfgtO.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/build-all.js +0 -5
- package/lib/build-stream-schema.js +1 -1
- package/lib/build.js +1809 -2646
- package/lib/consumer-fanout.js +1317 -0
- package/lib/escalation.js +69 -0
- package/lib/feature-validator.js +6 -4
- package/lib/feature-writer.js +58 -3
- package/lib/flow-state.js +15 -14
- package/lib/gsd-budget.js +48 -10
- package/lib/gsd-prompt.js +3 -4
- package/lib/gsd-stuck.js +1 -1
- package/lib/gsd.js +303 -149
- package/lib/lane-gate.js +200 -0
- package/lib/local-claude-connector.js +149 -0
- package/lib/new.js +162 -307
- package/lib/result-normalizer.js +233 -18
- package/lib/review-lenses.js +1 -1
- package/lib/step-prompt.js +41 -119
- package/lib/stratum-engine.js +297 -0
- package/lib/stratum-mcp-client.js +224 -213
- package/lib/triage.js +144 -0
- package/lib/vocabulary-compliance.js +268 -0
- package/lib/vocabulary-inject.js +1 -36
- package/package.json +2 -1
- package/pipelines/build-quick.stratum.yaml +5 -17
- package/pipelines/build.profiles.json +11 -0
- package/pipelines/build.stratum.yaml +288 -467
- package/pipelines/gsd.stratum.yaml +73 -125
- package/pipelines/new.stratum.yaml +68 -149
- package/server/build-routes.js +4 -1
- package/server/design-routes.js +37 -21
- package/server/index.js +13 -21
- package/server/lifecycle-guard.js +1 -1
- package/server/pipeline-routes.js +113 -31
- package/server/stratum-client.js +33 -47
- package/server/stratum-sync.js +3 -4
- package/server/vision-server.js +1 -1
- package/dist/assets/channel-CYErfopw.js +0 -1
- package/dist/assets/classDiagram-OUVF2IWQ-qCC_hnXu.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-qCC_hnXu.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-BWqk_9py.js +0 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-C60ICdks.js +0 -8
- package/lib/connector-factory-shim.js +0 -167
- package/server/agent-mcp.js +0 -10
|
@@ -0,0 +1,1317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-safe consumer-dispatch artifacts for the TS-native ready[] pump.
|
|
3
|
+
*
|
|
4
|
+
* All metadata and item worktrees live outside the merge target. Journal writes
|
|
5
|
+
* are fsync + atomic rename so a process kill cannot expose a half-record.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
closeSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
fsyncSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
openSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
realpathSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from 'node:fs';
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
22
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
|
|
25
|
+
const JOURNAL_VERSION = 1;
|
|
26
|
+
let tempIndexSequence = 0;
|
|
27
|
+
|
|
28
|
+
function sha256(value) {
|
|
29
|
+
return createHash('sha256').update(String(value)).digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function now() {
|
|
33
|
+
return new Date().toISOString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function git(cwd, args, opts = {}) {
|
|
37
|
+
const output = execFileSync('git', args, {
|
|
38
|
+
cwd,
|
|
39
|
+
encoding: opts.encoding ?? 'utf8',
|
|
40
|
+
input: opts.input,
|
|
41
|
+
env: opts.env ?? process.env,
|
|
42
|
+
timeout: opts.timeout ?? 30_000,
|
|
43
|
+
// A cumulative diff (git diff --cached --binary) can dwarf execFileSync's
|
|
44
|
+
// 1 MiB default stdout buffer. Without headroom a >1 MiB diff throws ENOBUFS
|
|
45
|
+
// at capture AND identically on every recovery re-run — a permanent wedge.
|
|
46
|
+
// Every git call in this module routes through here, so one ceiling covers
|
|
47
|
+
// diff capture, snapshots, and merges alike.
|
|
48
|
+
maxBuffer: opts.maxBuffer ?? 512 * 1024 * 1024,
|
|
49
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
50
|
+
});
|
|
51
|
+
return opts.trim === false ? output : output.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fsyncDirectory(path) {
|
|
55
|
+
let fd;
|
|
56
|
+
try {
|
|
57
|
+
fd = openSync(path, 'r');
|
|
58
|
+
fsyncSync(fd);
|
|
59
|
+
} finally {
|
|
60
|
+
if (fd !== undefined) closeSync(fd);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function durableWriteJson(path, value) {
|
|
65
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
66
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
67
|
+
let fd;
|
|
68
|
+
try {
|
|
69
|
+
fd = openSync(temp, 'wx', 0o600);
|
|
70
|
+
writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
71
|
+
fsyncSync(fd);
|
|
72
|
+
} finally {
|
|
73
|
+
if (fd !== undefined) closeSync(fd);
|
|
74
|
+
}
|
|
75
|
+
renameSync(temp, path);
|
|
76
|
+
fsyncDirectory(dirname(path));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Module-level, journal-path-keyed one-writer guard shared by every
|
|
80
|
+
// ConsumerFanoutArtifacts instance on the same file. Combined with the
|
|
81
|
+
// mutate-against-fresh primitive (#mutate reloads the on-disk journal before
|
|
82
|
+
// applying a change), it guarantees no write is ever based on a stale snapshot.
|
|
83
|
+
const journalWriters = new Set();
|
|
84
|
+
|
|
85
|
+
/** Resolve symlinks in every existing prefix, including for a not-yet-created leaf. */
|
|
86
|
+
function canonicalPath(path) {
|
|
87
|
+
let cursor = resolve(path);
|
|
88
|
+
const missing = [];
|
|
89
|
+
while (!existsSync(cursor)) {
|
|
90
|
+
const parent = dirname(cursor);
|
|
91
|
+
if (parent === cursor) break;
|
|
92
|
+
missing.unshift(basename(cursor));
|
|
93
|
+
cursor = parent;
|
|
94
|
+
}
|
|
95
|
+
const existing = existsSync(cursor) ? realpathSync(cursor) : cursor;
|
|
96
|
+
return resolve(existing, ...missing);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function withTemporaryIndex(cwd, fn) {
|
|
100
|
+
const indexPath = join(
|
|
101
|
+
tmpdir(),
|
|
102
|
+
`compose-consumer-index-${process.pid}-${tempIndexSequence++}-${randomUUID()}`,
|
|
103
|
+
);
|
|
104
|
+
const env = { ...process.env, GIT_INDEX_FILE: indexPath };
|
|
105
|
+
try {
|
|
106
|
+
return fn(env);
|
|
107
|
+
} finally {
|
|
108
|
+
rmSync(indexPath, { force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Snapshot tracked + untracked, non-ignored working content without touching the real index. */
|
|
113
|
+
export function snapshotWorkingTree(cwd) {
|
|
114
|
+
return withTemporaryIndex(cwd, (env) => {
|
|
115
|
+
git(cwd, ['read-tree', 'HEAD'], { env });
|
|
116
|
+
git(cwd, ['add', '-A'], { env });
|
|
117
|
+
return git(cwd, ['write-tree'], { env });
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function restoreWorkingTree(cwd, treeId) {
|
|
122
|
+
// This intentionally normalizes the staged/unstaged split, matching Compose's
|
|
123
|
+
// existing snapshot restore semantics. The final reset leaves HEAD/index alone
|
|
124
|
+
// while retaining the restored tree as working content.
|
|
125
|
+
try { git(cwd, ['clean', '-fd']); } catch { /* nothing untracked to remove */ }
|
|
126
|
+
git(cwd, ['read-tree', '--reset', '-u', treeId]);
|
|
127
|
+
git(cwd, ['reset', '-q', 'HEAD']);
|
|
128
|
+
const actual = snapshotWorkingTree(cwd);
|
|
129
|
+
if (actual !== treeId) {
|
|
130
|
+
throw new Error(`consumer snapshot restore verification failed: expected ${treeId}, got ${actual}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function cumulativeDiff(cwd, baseCommit) {
|
|
135
|
+
return withTemporaryIndex(cwd, (env) => {
|
|
136
|
+
git(cwd, ['read-tree', baseCommit], { env });
|
|
137
|
+
git(cwd, ['add', '-A'], { env });
|
|
138
|
+
return git(cwd, ['diff', '--cached', '--binary', baseCommit, '--'], {
|
|
139
|
+
env,
|
|
140
|
+
timeout: 60_000,
|
|
141
|
+
trim: false,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function computeWitnessChain(cwd, orderedEntries) {
|
|
147
|
+
return withTemporaryIndex(cwd, (env) => {
|
|
148
|
+
git(cwd, ['read-tree', 'HEAD'], { env });
|
|
149
|
+
git(cwd, ['add', '-A'], { env });
|
|
150
|
+
const chain = [git(cwd, ['write-tree'], { env })];
|
|
151
|
+
for (const entry of orderedEntries) {
|
|
152
|
+
if (entry.diff.length > 0) {
|
|
153
|
+
git(cwd, ['apply', '--cached', '--binary', '-'], {
|
|
154
|
+
env,
|
|
155
|
+
input: entry.diff,
|
|
156
|
+
timeout: 60_000,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
chain.push(git(cwd, ['write-tree'], { env }));
|
|
160
|
+
}
|
|
161
|
+
return chain;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function defaultArtifactRoot(targetCwd) {
|
|
166
|
+
const workspaceKey = sha256(resolve(targetCwd)).slice(0, 24);
|
|
167
|
+
return join(tmpdir(), 'compose-consumer-fanout', workspaceKey);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function runDirectoryName(runId) {
|
|
171
|
+
return `${String(runId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 48)}-${sha256(runId).slice(0, 12)}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function journalLocation({ runId, targetCwd, artifactRoot }) {
|
|
175
|
+
const target = canonicalPath(targetCwd);
|
|
176
|
+
const root = canonicalPath(artifactRoot ?? defaultArtifactRoot(target));
|
|
177
|
+
return join(root, runDirectoryName(runId), 'journal.json');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function worktreeKey(descriptor) {
|
|
181
|
+
return `${descriptor.id}@${descriptor.generation}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Python-parity: a fanout with `isolation: "none"` runs its items in the shared
|
|
186
|
+
* target cwd (no per-item worktree, no diff capture, no merge participation).
|
|
187
|
+
* Only `isolation: "worktree"` items own a detached worktree and owe a diff.
|
|
188
|
+
*/
|
|
189
|
+
function isNoneIsolation(descriptor) {
|
|
190
|
+
return descriptor?.policy?.isolation === 'none';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function terminalItem(item) {
|
|
194
|
+
return ['succeeded', 'failed', 'skipped', 'cancelled'].includes(item?.status);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function isConsumerDescriptor(entry) {
|
|
198
|
+
return Boolean(
|
|
199
|
+
entry
|
|
200
|
+
&& typeof entry === 'object'
|
|
201
|
+
&& typeof entry.dispatchToken === 'string'
|
|
202
|
+
&& entry.dispatchToken.length > 0
|
|
203
|
+
&& Number.isInteger(entry.itemIndex)
|
|
204
|
+
&& Number.isInteger(entry.stage)
|
|
205
|
+
&& Number.isInteger(entry.generation)
|
|
206
|
+
&& typeof entry.step === 'string'
|
|
207
|
+
&& entry.policy
|
|
208
|
+
&& typeof entry.policy === 'object',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export class ConsumerArtifactError extends Error {
|
|
213
|
+
constructor(code, message, detail = {}) {
|
|
214
|
+
super(message);
|
|
215
|
+
this.name = 'ConsumerArtifactError';
|
|
216
|
+
this.code = code;
|
|
217
|
+
this.detail = detail;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export class ConsumerMergeDecisionError extends ConsumerArtifactError {
|
|
222
|
+
constructor(code, message, detail = {}) {
|
|
223
|
+
super(code, message, detail);
|
|
224
|
+
this.name = 'ConsumerMergeDecisionError';
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export class ConsumerFanoutArtifacts {
|
|
229
|
+
constructor({ runId, targetCwd, artifactRoot, hooks = {}, revisionDigest, specDigest }) {
|
|
230
|
+
this.runId = runId;
|
|
231
|
+
this.targetCwd = canonicalPath(targetCwd);
|
|
232
|
+
this.artifactRoot = canonicalPath(artifactRoot ?? defaultArtifactRoot(this.targetCwd));
|
|
233
|
+
const artifactRelative = relative(this.targetCwd, this.artifactRoot);
|
|
234
|
+
if (artifactRelative === ''
|
|
235
|
+
|| (!artifactRelative.startsWith('..') && !isAbsolute(artifactRelative))) {
|
|
236
|
+
throw new ConsumerArtifactError(
|
|
237
|
+
'ARTIFACT_ROOT_INSIDE_TARGET',
|
|
238
|
+
`consumer artifact root must be outside the merge target: ${this.artifactRoot}`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
// Revision pins are written into the FIRST durable journal write (below), so
|
|
242
|
+
// a crash between journal creation and the first bind cannot leave an
|
|
243
|
+
// unpinned journal that a drifted spec could later re-pin as the truth.
|
|
244
|
+
this._initialRevisionDigest = typeof revisionDigest === 'string' && revisionDigest.length > 0 ? revisionDigest : null;
|
|
245
|
+
this._initialSpecDigest = typeof specDigest === 'string' && specDigest.length > 0 ? specDigest : null;
|
|
246
|
+
this.runRoot = join(this.artifactRoot, runDirectoryName(runId));
|
|
247
|
+
this.journalPath = join(this.runRoot, 'journal.json');
|
|
248
|
+
this.hooks = hooks ?? {};
|
|
249
|
+
mkdirSync(this.runRoot, { recursive: true });
|
|
250
|
+
this.journal = this.#load();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#writeGuarded(value) {
|
|
254
|
+
// Module-level one-writer guard keyed by journal PATH — coordinates every
|
|
255
|
+
// ConsumerFanoutArtifacts instance on the same file, not just this instance.
|
|
256
|
+
if (journalWriters.has(this.journalPath)) {
|
|
257
|
+
throw new ConsumerArtifactError(
|
|
258
|
+
'JOURNAL_WRITER_REENTRY',
|
|
259
|
+
`consumer journal already has an active writer: ${this.journalPath}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
journalWriters.add(this.journalPath);
|
|
263
|
+
try {
|
|
264
|
+
durableWriteJson(this.journalPath, value);
|
|
265
|
+
} finally {
|
|
266
|
+
journalWriters.delete(this.journalPath);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Single-source-of-truth mutation: apply a synchronous change to the CURRENT
|
|
272
|
+
* on-disk journal, never to a possibly-stale in-memory base. Under the one-writer
|
|
273
|
+
* guard: reload fresh from disk, run `fn` (which mutates `this.journal`), durably
|
|
274
|
+
* write, and leave `this.journal` as exactly what was written. The in-memory model
|
|
275
|
+
* is thereby a read cache only. Re-entrant: a nested mutation just runs against the
|
|
276
|
+
* already-fresh `this.journal` and lets the outer write persist it.
|
|
277
|
+
*/
|
|
278
|
+
#mutate(fn) {
|
|
279
|
+
if (this._inMutation) return fn();
|
|
280
|
+
if (journalWriters.has(this.journalPath)) {
|
|
281
|
+
throw new ConsumerArtifactError(
|
|
282
|
+
'JOURNAL_WRITER_REENTRY',
|
|
283
|
+
`consumer journal already has an active writer: ${this.journalPath}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
journalWriters.add(this.journalPath);
|
|
287
|
+
this._inMutation = true;
|
|
288
|
+
try {
|
|
289
|
+
this.journal = this.#reload();
|
|
290
|
+
const result = fn();
|
|
291
|
+
this.journal.updatedAt = now();
|
|
292
|
+
durableWriteJson(this.journalPath, this.journal);
|
|
293
|
+
return result;
|
|
294
|
+
} finally {
|
|
295
|
+
this._inMutation = false;
|
|
296
|
+
journalWriters.delete(this.journalPath);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#save() {
|
|
301
|
+
this.journal.updatedAt = now();
|
|
302
|
+
if (this._inMutation) {
|
|
303
|
+
// Interim durable write within an active mutation (the guard is already held).
|
|
304
|
+
durableWriteJson(this.journalPath, this.journal);
|
|
305
|
+
} else {
|
|
306
|
+
this.#writeGuarded(this.journal);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Read + validate + default + adopt-pins the existing on-disk journal. No write. */
|
|
311
|
+
#reload() {
|
|
312
|
+
const journal = JSON.parse(readFileSync(this.journalPath, 'utf8'));
|
|
313
|
+
if (journal.version !== JOURNAL_VERSION || journal.runId !== this.runId) {
|
|
314
|
+
throw new ConsumerArtifactError('JOURNAL_IDENTITY_MISMATCH', 'consumer artifact journal identity mismatch');
|
|
315
|
+
}
|
|
316
|
+
if (resolve(journal.targetCwd) !== this.targetCwd) {
|
|
317
|
+
throw new ConsumerArtifactError(
|
|
318
|
+
'JOURNAL_TARGET_MISMATCH',
|
|
319
|
+
`consumer artifact journal targets ${journal.targetCwd}, not ${this.targetCwd}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
journal.revisionDigest ??= null;
|
|
323
|
+
journal.specDigest ??= null;
|
|
324
|
+
journal.gateBinding ??= null;
|
|
325
|
+
journal.worktrees ??= [];
|
|
326
|
+
journal.witnesses ??= [];
|
|
327
|
+
journal.issuances ??= [];
|
|
328
|
+
journal.mergeTransactions ??= [];
|
|
329
|
+
// Adopt constructor-supplied pins onto an EXISTING unpinned journal — a legacy
|
|
330
|
+
// pre-pinning journal, or one first created at the gate path before this
|
|
331
|
+
// manager was constructed. An already-pinned journal keeps its pins; a
|
|
332
|
+
// constructor pin that DIFFERS from a persisted one is a real revision drift.
|
|
333
|
+
if (this._initialRevisionDigest) {
|
|
334
|
+
if (!journal.revisionDigest) journal.revisionDigest = this._initialRevisionDigest;
|
|
335
|
+
else if (journal.revisionDigest !== this._initialRevisionDigest) {
|
|
336
|
+
throw new ConsumerArtifactError(
|
|
337
|
+
'CONSUMER_RUN_REVISION_MISMATCH',
|
|
338
|
+
`consumer journal revision ${journal.revisionDigest} does not match run revision ${this._initialRevisionDigest}`,
|
|
339
|
+
{ journaledRevisionDigest: journal.revisionDigest, runRevisionDigest: this._initialRevisionDigest },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (this._initialSpecDigest) {
|
|
344
|
+
if (!journal.specDigest) journal.specDigest = this._initialSpecDigest;
|
|
345
|
+
else if (journal.specDigest !== this._initialSpecDigest) {
|
|
346
|
+
throw new ConsumerArtifactError(
|
|
347
|
+
'CONSUMER_RUN_REVISION_MISMATCH',
|
|
348
|
+
`consumer journal spec ${journal.specDigest} does not match local spec ${this._initialSpecDigest}`,
|
|
349
|
+
{ journaledSpecDigest: journal.specDigest, currentSpecDigest: this._initialSpecDigest },
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return journal;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#load() {
|
|
357
|
+
if (!existsSync(this.journalPath)) {
|
|
358
|
+
const journal = {
|
|
359
|
+
version: JOURNAL_VERSION,
|
|
360
|
+
runId: this.runId,
|
|
361
|
+
targetCwd: this.targetCwd,
|
|
362
|
+
createdAt: now(),
|
|
363
|
+
updatedAt: now(),
|
|
364
|
+
// The run revision this journal belongs to. `revisionDigest` is the
|
|
365
|
+
// engine's effective-spec digest (echoed by every descriptor); `specDigest`
|
|
366
|
+
// is Compose's fingerprint of the local pipeline spec it derives final-stage
|
|
367
|
+
// and merge-gate ownership from. Both are pinned in THIS initial write when
|
|
368
|
+
// available, and re-checked on resume so a mid-run spec edit cannot silently
|
|
369
|
+
// strand diffs.
|
|
370
|
+
revisionDigest: this._initialRevisionDigest,
|
|
371
|
+
specDigest: this._initialSpecDigest,
|
|
372
|
+
gateBinding: null,
|
|
373
|
+
worktrees: [],
|
|
374
|
+
witnesses: [],
|
|
375
|
+
issuances: [],
|
|
376
|
+
mergeTransactions: [],
|
|
377
|
+
};
|
|
378
|
+
this.#writeGuarded(journal);
|
|
379
|
+
return journal;
|
|
380
|
+
}
|
|
381
|
+
// Existing journal: reload (validates + adopts pins). Persist an adoption so a
|
|
382
|
+
// legacy/gate-first-created journal is pinned even if no mutation follows.
|
|
383
|
+
const before = readFileSync(this.journalPath, 'utf8');
|
|
384
|
+
const journal = this.#reload();
|
|
385
|
+
if ((this._initialRevisionDigest && journal.revisionDigest !== JSON.parse(before).revisionDigest)
|
|
386
|
+
|| (this._initialSpecDigest && journal.specDigest !== JSON.parse(before).specDigest)) {
|
|
387
|
+
this.#writeGuarded(journal);
|
|
388
|
+
}
|
|
389
|
+
return journal;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#worktreeFor(descriptor) {
|
|
393
|
+
return this.journal.worktrees.find((entry) => entry.key === worktreeKey(descriptor));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#ensureWorktree(descriptor) {
|
|
397
|
+
const key = worktreeKey(descriptor);
|
|
398
|
+
let record = this.#worktreeFor(descriptor);
|
|
399
|
+
if (record) {
|
|
400
|
+
if (existsSync(record.path)) {
|
|
401
|
+
try {
|
|
402
|
+
if (git(record.path, ['rev-parse', '--is-inside-work-tree']) === 'true') {
|
|
403
|
+
if (record.status !== 'ready') {
|
|
404
|
+
record.status = 'ready';
|
|
405
|
+
record.readyAt = now();
|
|
406
|
+
this.#save();
|
|
407
|
+
}
|
|
408
|
+
return record;
|
|
409
|
+
}
|
|
410
|
+
} catch { /* partial worktree creation; rebuild below */ }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (record.status === 'creating') {
|
|
414
|
+
try { git(this.targetCwd, ['worktree', 'prune']); } catch { /* best effort */ }
|
|
415
|
+
rmSync(record.path, { recursive: true, force: true });
|
|
416
|
+
mkdirSync(dirname(record.path), { recursive: true });
|
|
417
|
+
git(this.targetCwd, ['worktree', 'add', '--detach', record.path, record.baseCommit], { timeout: 60_000 });
|
|
418
|
+
record.status = 'ready';
|
|
419
|
+
record.readyAt = now();
|
|
420
|
+
record.recoveredAt = now();
|
|
421
|
+
this.#save();
|
|
422
|
+
return record;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Only the CURRENT issuance's pre-stage witness is a safe reconstruction
|
|
426
|
+
// point. An older stage witness predates that stage's filesystem mutation
|
|
427
|
+
// and would silently discard persistent `${prev}` state.
|
|
428
|
+
const witness = this.journal.witnesses.find(
|
|
429
|
+
(entry) => entry.worktreeKey === key
|
|
430
|
+
&& entry.dispatchToken === descriptor.dispatchToken,
|
|
431
|
+
);
|
|
432
|
+
if (!witness) {
|
|
433
|
+
throw new ConsumerArtifactError(
|
|
434
|
+
'ITEM_WORKTREE_LOST',
|
|
435
|
+
`consumer worktree artifact was lost for ${descriptor.id} generation ${descriptor.generation}`,
|
|
436
|
+
{ descriptor },
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
try { git(this.targetCwd, ['worktree', 'prune']); } catch { /* best effort */ }
|
|
440
|
+
rmSync(record.path, { recursive: true, force: true });
|
|
441
|
+
git(this.targetCwd, ['worktree', 'add', '--detach', record.path, record.baseCommit], { timeout: 60_000 });
|
|
442
|
+
restoreWorkingTree(record.path, witness.witnessTree);
|
|
443
|
+
record.status = 'ready';
|
|
444
|
+
record.recoveredAt = now();
|
|
445
|
+
this.#save();
|
|
446
|
+
return record;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (const prior of this.journal.worktrees) {
|
|
450
|
+
if (prior.scopedId === descriptor.id && prior.generation !== descriptor.generation && !prior.superseded) {
|
|
451
|
+
prior.superseded = true;
|
|
452
|
+
prior.supersededAt = now();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const issuance of this.journal.issuances) {
|
|
456
|
+
if (issuance.scopedId === descriptor.id
|
|
457
|
+
&& issuance.generation !== descriptor.generation
|
|
458
|
+
&& issuance.state !== 'merged'
|
|
459
|
+
&& issuance.state !== 'superseded') {
|
|
460
|
+
issuance.state = 'superseded';
|
|
461
|
+
issuance.supersededAt = now();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const path = join(this.runRoot, 'worktrees', sha256(key).slice(0, 32));
|
|
466
|
+
record = {
|
|
467
|
+
key,
|
|
468
|
+
scopedId: descriptor.id,
|
|
469
|
+
fanoutStepId: descriptor.step,
|
|
470
|
+
itemIndex: descriptor.itemIndex,
|
|
471
|
+
generation: descriptor.generation,
|
|
472
|
+
path,
|
|
473
|
+
baseCommit: git(this.targetCwd, ['rev-parse', 'HEAD']),
|
|
474
|
+
status: 'creating',
|
|
475
|
+
superseded: false,
|
|
476
|
+
createdAt: now(),
|
|
477
|
+
};
|
|
478
|
+
this.journal.worktrees.push(record);
|
|
479
|
+
this.#save();
|
|
480
|
+
|
|
481
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
482
|
+
try {
|
|
483
|
+
git(this.targetCwd, ['worktree', 'add', '--detach', path, record.baseCommit], { timeout: 60_000 });
|
|
484
|
+
} catch (error) {
|
|
485
|
+
try { git(this.targetCwd, ['worktree', 'prune']); } catch { /* best effort */ }
|
|
486
|
+
rmSync(path, { recursive: true, force: true });
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
record.status = 'ready';
|
|
490
|
+
record.readyAt = now();
|
|
491
|
+
this.#save();
|
|
492
|
+
return record;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
#auditItem(audit, fanoutStepId, itemIndex) {
|
|
496
|
+
return audit?.steps?.[fanoutStepId]?.fanout?.items?.[itemIndex] ?? null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Pin the run revision + local-spec fingerprint at the first consumer issuance.
|
|
501
|
+
* First-write-wins; a later issuance carrying a DIFFERENT engine revision digest
|
|
502
|
+
* is a mid-run revision the journal must reject rather than silently absorb.
|
|
503
|
+
*/
|
|
504
|
+
bindRunRevision({ revisionDigest, specDigest }) {
|
|
505
|
+
return this.#mutate(() => {
|
|
506
|
+
if (typeof revisionDigest === 'string' && revisionDigest.length > 0) {
|
|
507
|
+
if (this.journal.revisionDigest === null) {
|
|
508
|
+
this.journal.revisionDigest = revisionDigest;
|
|
509
|
+
} else if (this.journal.revisionDigest !== revisionDigest) {
|
|
510
|
+
throw new ConsumerArtifactError(
|
|
511
|
+
'CONSUMER_RUN_REVISION_MISMATCH',
|
|
512
|
+
`consumer run revision changed mid-run: journaled ${this.journal.revisionDigest}, descriptor ${revisionDigest}`,
|
|
513
|
+
{ journaledRevisionDigest: this.journal.revisionDigest, descriptorRevisionDigest: revisionDigest },
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (typeof specDigest === 'string' && specDigest.length > 0 && this.journal.specDigest === null) {
|
|
518
|
+
this.journal.specDigest = specDigest;
|
|
519
|
+
}
|
|
520
|
+
return this.journal;
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Record which fanout step a merge gate settles, so resume never re-derives it
|
|
525
|
+
* from a mutable local spec that may have drifted. First-write-wins per gate. */
|
|
526
|
+
recordGateBinding({ gateStepId, fanoutStepId }) {
|
|
527
|
+
if (!gateStepId || !fanoutStepId) return;
|
|
528
|
+
this.#mutate(() => {
|
|
529
|
+
const existing = this.journal.gateBinding ?? {};
|
|
530
|
+
if (existing[gateStepId] === fanoutStepId) return;
|
|
531
|
+
if (existing[gateStepId] && existing[gateStepId] !== fanoutStepId) {
|
|
532
|
+
throw new ConsumerArtifactError(
|
|
533
|
+
'CONSUMER_GATE_BINDING_MISMATCH',
|
|
534
|
+
`merge gate ${gateStepId} was journaled against fanout ${existing[gateStepId]}, not ${fanoutStepId}`,
|
|
535
|
+
{ gateStepId, journaledFanoutStepId: existing[gateStepId], fanoutStepId },
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
this.journal.gateBinding = { ...existing, [gateStepId]: fanoutStepId };
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Reconcile journal records against an audit. `scope` bounds which records may
|
|
544
|
+
* be mutated: a per-item settlement (concurrent, possibly-stale snapshot) must
|
|
545
|
+
* pass `{ fanoutStepId, itemIndex }` so it touches ONLY its own item's records —
|
|
546
|
+
* an unordered stale snapshot from one item must never supersede another item's
|
|
547
|
+
* newer issuance (C1). Global reconciliation (merge-gate discovery, resume
|
|
548
|
+
* recovery) passes no scope and runs only at ordered, single-threaded points
|
|
549
|
+
* with a fresh audit.
|
|
550
|
+
*/
|
|
551
|
+
reconcileAudit(audit, scope = null) {
|
|
552
|
+
return this.#mutate(() => this.#reconcileAuditInto(audit, scope));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
#reconcileAuditInto(audit, scope) {
|
|
556
|
+
const inScope = (fanoutStepId, itemIndex) => !scope
|
|
557
|
+
|| (scope.fanoutStepId === fanoutStepId && scope.itemIndex === itemIndex);
|
|
558
|
+
let changed = false;
|
|
559
|
+
for (const issuance of this.journal.issuances) {
|
|
560
|
+
if (issuance.state === 'merged' || issuance.state === 'superseded') continue;
|
|
561
|
+
if (!inScope(issuance.fanoutStepId, issuance.itemIndex)) continue;
|
|
562
|
+
const fanoutItems = audit?.steps?.[issuance.fanoutStepId]?.fanout?.items;
|
|
563
|
+
const item = Array.isArray(fanoutItems) ? fanoutItems[issuance.itemIndex] : undefined;
|
|
564
|
+
if (!item) {
|
|
565
|
+
// The fanout has no item at this index. If the fanout IS present in the
|
|
566
|
+
// audit (re-enumeration produced FEWER items), the issuance's evidence is
|
|
567
|
+
// stale ground-truth — supersede it so a later merge never resurrects a
|
|
568
|
+
// diff for an item index that no longer exists. If the fanout is absent
|
|
569
|
+
// from the audit entirely, there is nothing to reconcile against; leave it.
|
|
570
|
+
if (Array.isArray(fanoutItems)) {
|
|
571
|
+
issuance.state = 'superseded';
|
|
572
|
+
issuance.supersededAt = now();
|
|
573
|
+
changed = true;
|
|
574
|
+
}
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (item.generation !== issuance.generation) {
|
|
578
|
+
issuance.state = 'superseded';
|
|
579
|
+
issuance.supersededAt = now();
|
|
580
|
+
changed = true;
|
|
581
|
+
} else if (item.status === 'succeeded'
|
|
582
|
+
&& item.acceptedDispatchToken === issuance.dispatchToken) {
|
|
583
|
+
issuance.state = 'accepted';
|
|
584
|
+
issuance.acceptedAt = now();
|
|
585
|
+
changed = true;
|
|
586
|
+
} else if (terminalItem(item)
|
|
587
|
+
|| (typeof item.dispatchToken === 'string' && item.dispatchToken !== issuance.dispatchToken)) {
|
|
588
|
+
issuance.state = 'superseded';
|
|
589
|
+
issuance.supersededAt = now();
|
|
590
|
+
changed = true;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
for (const worktree of this.journal.worktrees) {
|
|
595
|
+
if (!inScope(worktree.fanoutStepId, worktree.itemIndex)) continue;
|
|
596
|
+
const item = this.#auditItem(audit, worktree.fanoutStepId, worktree.itemIndex);
|
|
597
|
+
if (item && item.generation !== worktree.generation && !worktree.superseded) {
|
|
598
|
+
worktree.superseded = true;
|
|
599
|
+
worktree.supersededAt = now();
|
|
600
|
+
changed = true;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (changed) this.#save();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Reconcile a ready issuance. Returns either a stored envelope to re-report,
|
|
608
|
+
* or a verified worktree path in which the stage may execute.
|
|
609
|
+
*/
|
|
610
|
+
reconcileDescriptor(descriptor, audit) {
|
|
611
|
+
return this.#mutate(() => {
|
|
612
|
+
// Per-item settlement: the descriptor's audit is a concurrent snapshot that
|
|
613
|
+
// may be stale for OTHER items, so reconcile only this item's own records.
|
|
614
|
+
this.#reconcileAuditInto(audit, { fanoutStepId: descriptor.step, itemIndex: descriptor.itemIndex });
|
|
615
|
+
const prepared = this.journal.issuances.find(
|
|
616
|
+
(entry) => entry.dispatchToken === descriptor.dispatchToken,
|
|
617
|
+
);
|
|
618
|
+
if (prepared?.state === 'prepared') {
|
|
619
|
+
return { action: 'report', envelope: structuredClone(prepared.envelope), issuance: prepared };
|
|
620
|
+
}
|
|
621
|
+
if (prepared?.state === 'accepted') {
|
|
622
|
+
return { action: 'accepted', issuance: prepared };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (isNoneIsolation(descriptor)) {
|
|
626
|
+
// In-cwd execution: no worktree, no pre-stage witness, no diff. A crash
|
|
627
|
+
// before the prepared envelope simply re-executes in the target on retry
|
|
628
|
+
// (Python parity), and the envelope journaling below still guards a crash
|
|
629
|
+
// between prepare and step_done.
|
|
630
|
+
return { action: 'execute', worktree: this.targetCwd, witness: null, isolation: 'none' };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const worktree = this.#ensureWorktree(descriptor);
|
|
634
|
+
const witness = this.journal.witnesses.find(
|
|
635
|
+
(entry) => entry.dispatchToken === descriptor.dispatchToken,
|
|
636
|
+
);
|
|
637
|
+
if (witness) {
|
|
638
|
+
restoreWorkingTree(worktree.path, witness.witnessTree);
|
|
639
|
+
witness.restoredAt = now();
|
|
640
|
+
witness.restoreCount = (witness.restoreCount ?? 0) + 1;
|
|
641
|
+
return { action: 'execute', worktree: worktree.path, witness, restored: true };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const newWitness = {
|
|
645
|
+
dispatchToken: descriptor.dispatchToken,
|
|
646
|
+
scopedId: descriptor.id,
|
|
647
|
+
fanoutStepId: descriptor.step,
|
|
648
|
+
itemIndex: descriptor.itemIndex,
|
|
649
|
+
generation: descriptor.generation,
|
|
650
|
+
stage: descriptor.stage,
|
|
651
|
+
attempt: descriptor.attempt,
|
|
652
|
+
worktreeKey: worktree.key,
|
|
653
|
+
witnessTree: snapshotWorkingTree(worktree.path),
|
|
654
|
+
createdAt: now(),
|
|
655
|
+
};
|
|
656
|
+
this.journal.witnesses.push(newWitness);
|
|
657
|
+
return { action: 'execute', worktree: worktree.path, witness: newWitness, restored: false };
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Restore a still-ready issuance's worktree to its pre-stage witness. Used when
|
|
663
|
+
* the agent/connector threw mid-stage: the retry (or the failure report) must
|
|
664
|
+
* start from the clean pre-stage snapshot, not a half-mutated worktree.
|
|
665
|
+
* Returns false when there is nothing to restore (no worktree or no witness).
|
|
666
|
+
*/
|
|
667
|
+
restoreToPreStageWitness(descriptor) {
|
|
668
|
+
return this.#mutate(() => {
|
|
669
|
+
const worktree = this.#worktreeFor(descriptor);
|
|
670
|
+
const witness = this.journal.witnesses.find(
|
|
671
|
+
(entry) => entry.dispatchToken === descriptor.dispatchToken,
|
|
672
|
+
);
|
|
673
|
+
if (!worktree || !witness || !existsSync(worktree.path)) return false;
|
|
674
|
+
restoreWorkingTree(worktree.path, witness.witnessTree);
|
|
675
|
+
witness.restoredAt = now();
|
|
676
|
+
witness.restoreCount = (witness.restoreCount ?? 0) + 1;
|
|
677
|
+
return true;
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
prepareIssuance(descriptor, envelope, { finalStage }) {
|
|
682
|
+
return this.#mutate(() => {
|
|
683
|
+
const existing = this.journal.issuances.find(
|
|
684
|
+
(entry) => entry.dispatchToken === descriptor.dispatchToken,
|
|
685
|
+
);
|
|
686
|
+
if (existing) return existing;
|
|
687
|
+
|
|
688
|
+
if (isNoneIsolation(descriptor)) {
|
|
689
|
+
// Envelope-only journal entry: no worktree, no witness, no diff. It never
|
|
690
|
+
// participates in the merge (prepareMerge filters isolation:none out), so
|
|
691
|
+
// its files persist directly in the target cwd.
|
|
692
|
+
const entry = {
|
|
693
|
+
dispatchToken: descriptor.dispatchToken,
|
|
694
|
+
scopedId: descriptor.id,
|
|
695
|
+
fanoutStepId: descriptor.step,
|
|
696
|
+
itemIndex: descriptor.itemIndex,
|
|
697
|
+
generation: descriptor.generation,
|
|
698
|
+
stage: descriptor.stage,
|
|
699
|
+
attempt: descriptor.attempt,
|
|
700
|
+
revisionDigest: descriptor.revisionDigest,
|
|
701
|
+
contractDigest: descriptor.contractDigest,
|
|
702
|
+
isolation: 'none',
|
|
703
|
+
worktreeKey: null,
|
|
704
|
+
witnessTree: null,
|
|
705
|
+
state: 'prepared',
|
|
706
|
+
envelope: structuredClone(envelope),
|
|
707
|
+
diff: null,
|
|
708
|
+
hadCumulativeDiff: false,
|
|
709
|
+
diffDigest: null,
|
|
710
|
+
finalStage: finalStage === true,
|
|
711
|
+
preparedAt: now(),
|
|
712
|
+
};
|
|
713
|
+
this.journal.issuances.push(entry);
|
|
714
|
+
return entry;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const worktree = this.#worktreeFor(descriptor);
|
|
718
|
+
const witness = this.journal.witnesses.find(
|
|
719
|
+
(entry) => entry.dispatchToken === descriptor.dispatchToken,
|
|
720
|
+
);
|
|
721
|
+
if (!worktree || !witness) {
|
|
722
|
+
throw new ConsumerArtifactError(
|
|
723
|
+
'ISSUANCE_WITNESS_MISSING',
|
|
724
|
+
`cannot prepare ${descriptor.id}: durable pre-stage witness is missing`,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
const diff = finalStage ? cumulativeDiff(worktree.path, worktree.baseCommit) : null;
|
|
728
|
+
const entry = {
|
|
729
|
+
dispatchToken: descriptor.dispatchToken,
|
|
730
|
+
scopedId: descriptor.id,
|
|
731
|
+
fanoutStepId: descriptor.step,
|
|
732
|
+
itemIndex: descriptor.itemIndex,
|
|
733
|
+
generation: descriptor.generation,
|
|
734
|
+
stage: descriptor.stage,
|
|
735
|
+
attempt: descriptor.attempt,
|
|
736
|
+
revisionDigest: descriptor.revisionDigest,
|
|
737
|
+
contractDigest: descriptor.contractDigest,
|
|
738
|
+
isolation: 'worktree',
|
|
739
|
+
worktreeKey: worktree.key,
|
|
740
|
+
witnessTree: witness.witnessTree,
|
|
741
|
+
state: 'prepared',
|
|
742
|
+
envelope: structuredClone(envelope),
|
|
743
|
+
diff,
|
|
744
|
+
hadCumulativeDiff: diff !== null,
|
|
745
|
+
diffDigest: diff === null ? null : sha256(diff),
|
|
746
|
+
preparedAt: now(),
|
|
747
|
+
};
|
|
748
|
+
this.journal.issuances.push(entry);
|
|
749
|
+
return entry;
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
prepareArtifactFailure(descriptor, envelope, error) {
|
|
754
|
+
return this.#mutate(() => {
|
|
755
|
+
const existing = this.journal.issuances.find(
|
|
756
|
+
(entry) => entry.dispatchToken === descriptor.dispatchToken,
|
|
757
|
+
);
|
|
758
|
+
if (existing) return existing;
|
|
759
|
+
const entry = {
|
|
760
|
+
dispatchToken: descriptor.dispatchToken,
|
|
761
|
+
scopedId: descriptor.id,
|
|
762
|
+
fanoutStepId: descriptor.step,
|
|
763
|
+
itemIndex: descriptor.itemIndex,
|
|
764
|
+
generation: descriptor.generation,
|
|
765
|
+
stage: descriptor.stage,
|
|
766
|
+
attempt: descriptor.attempt,
|
|
767
|
+
revisionDigest: descriptor.revisionDigest,
|
|
768
|
+
contractDigest: descriptor.contractDigest,
|
|
769
|
+
worktreeKey: worktreeKey(descriptor),
|
|
770
|
+
witnessTree: null,
|
|
771
|
+
state: 'prepared',
|
|
772
|
+
envelope: structuredClone(envelope),
|
|
773
|
+
diff: null,
|
|
774
|
+
hadCumulativeDiff: false,
|
|
775
|
+
diffDigest: null,
|
|
776
|
+
artifactFailure: { code: error.code, message: error.message },
|
|
777
|
+
preparedAt: now(),
|
|
778
|
+
};
|
|
779
|
+
this.journal.issuances.push(entry);
|
|
780
|
+
return entry;
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
acceptedEntriesFor(fanoutStepId) {
|
|
785
|
+
return this.journal.issuances
|
|
786
|
+
.filter((entry) => entry.fanoutStepId === fanoutStepId && entry.state === 'accepted')
|
|
787
|
+
.sort((a, b) => a.itemIndex - b.itemIndex
|
|
788
|
+
|| a.scopedId.localeCompare(b.scopedId)
|
|
789
|
+
|| a.generation - b.generation);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
prepareMerge({ gateStepId, gateToken, fanoutStepId, audit }) {
|
|
793
|
+
return this.#mutate(() => {
|
|
794
|
+
this.#reconcileAuditInto(audit, null);
|
|
795
|
+
const existing = this.journal.mergeTransactions.find((entry) => entry.gateToken === gateToken);
|
|
796
|
+
if (existing?.state === 'blocked') {
|
|
797
|
+
throw new ConsumerMergeDecisionError(
|
|
798
|
+
existing.failureCode ?? 'MERGE_TRANSACTION_BLOCKED',
|
|
799
|
+
existing.failure ?? 'consumer merge transaction is blocked',
|
|
800
|
+
{ gateToken },
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
if (existing) return existing;
|
|
804
|
+
|
|
805
|
+
const accepted = this.acceptedEntriesFor(fanoutStepId);
|
|
806
|
+
// Only isolation:worktree items own a diff and participate in the merge.
|
|
807
|
+
// isolation:none items already wrote in the target cwd; they are accepted
|
|
808
|
+
// evidence but owe no diff (a pure-none fanout yields zero ordered diffs).
|
|
809
|
+
const worktreeAccepted = accepted.filter((entry) => entry.isolation !== 'none');
|
|
810
|
+
const baselineTree = snapshotWorkingTree(this.targetCwd);
|
|
811
|
+
const orderedDiffs = worktreeAccepted.map((entry) => ({
|
|
812
|
+
dispatchToken: entry.dispatchToken,
|
|
813
|
+
scopedId: entry.scopedId,
|
|
814
|
+
itemIndex: entry.itemIndex,
|
|
815
|
+
generation: entry.generation,
|
|
816
|
+
digest: entry.diffDigest,
|
|
817
|
+
diff: entry.diff,
|
|
818
|
+
}));
|
|
819
|
+
const recordBlocked = (code, message, witnessChain = [baselineTree]) => {
|
|
820
|
+
const transaction = {
|
|
821
|
+
gateStepId,
|
|
822
|
+
gateToken,
|
|
823
|
+
fanoutStepId,
|
|
824
|
+
state: 'blocked',
|
|
825
|
+
baselineTree,
|
|
826
|
+
witnessChain,
|
|
827
|
+
orderedDiffs,
|
|
828
|
+
recovery: { baselineRestores: 0 },
|
|
829
|
+
failureCode: code,
|
|
830
|
+
failure: `${code}: ${message}`,
|
|
831
|
+
preparedAt: now(),
|
|
832
|
+
};
|
|
833
|
+
this.journal.mergeTransactions.push(transaction);
|
|
834
|
+
this.#save();
|
|
835
|
+
return transaction;
|
|
836
|
+
};
|
|
837
|
+
const auditItems = audit?.steps?.[fanoutStepId]?.fanout?.items ?? [];
|
|
838
|
+
const succeeded = auditItems.filter((item) => item?.status === 'succeeded');
|
|
839
|
+
// Every succeeded item must have an accepted issuance; only worktree items
|
|
840
|
+
// must additionally carry a captured diff.
|
|
841
|
+
if (accepted.length !== succeeded.length || worktreeAccepted.some((entry) => entry.diff === null)) {
|
|
842
|
+
const detail = `merge gate ${gateStepId} has ${succeeded.length} succeeded items but `
|
|
843
|
+
+ `${accepted.length} accepted (${worktreeAccepted.length} worktree diffs)`;
|
|
844
|
+
recordBlocked('ACCEPTED_ARTIFACTS_INCOMPLETE', detail);
|
|
845
|
+
throw new ConsumerMergeDecisionError(
|
|
846
|
+
'ACCEPTED_ARTIFACTS_INCOMPLETE', detail, { gateStepId, fanoutStepId },
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
let witnessChain;
|
|
851
|
+
try {
|
|
852
|
+
witnessChain = computeWitnessChain(this.targetCwd, worktreeAccepted);
|
|
853
|
+
} catch (error) {
|
|
854
|
+
recordBlocked(
|
|
855
|
+
'MERGE_WITNESS_PRECOMPUTE_FAILED',
|
|
856
|
+
`consumer merge witness precompute failed: ${error.message}`,
|
|
857
|
+
);
|
|
858
|
+
throw new ConsumerMergeDecisionError(
|
|
859
|
+
'MERGE_WITNESS_PRECOMPUTE_FAILED',
|
|
860
|
+
`consumer merge witness precompute failed: ${error.message}`,
|
|
861
|
+
{ cause: error },
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
if (witnessChain[0] !== baselineTree) {
|
|
865
|
+
recordBlocked(
|
|
866
|
+
'MERGE_BASELINE_CHANGED_DURING_PRECOMPUTE',
|
|
867
|
+
`consumer merge baseline changed from ${baselineTree} to ${witnessChain[0]}`,
|
|
868
|
+
witnessChain,
|
|
869
|
+
);
|
|
870
|
+
throw new ConsumerMergeDecisionError(
|
|
871
|
+
'MERGE_BASELINE_CHANGED_DURING_PRECOMPUTE',
|
|
872
|
+
'consumer merge baseline changed during witness precompute',
|
|
873
|
+
{ witnessChain },
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
if (new Set(witnessChain).size !== witnessChain.length) {
|
|
877
|
+
recordBlocked(
|
|
878
|
+
'MERGE_WITNESS_NOT_UNIQUE',
|
|
879
|
+
'consumer merge aborted: expected tree-witness chain is not unique',
|
|
880
|
+
witnessChain,
|
|
881
|
+
);
|
|
882
|
+
throw new ConsumerMergeDecisionError(
|
|
883
|
+
'MERGE_WITNESS_NOT_UNIQUE',
|
|
884
|
+
'consumer merge aborted: expected tree-witness chain is not unique',
|
|
885
|
+
{ witnessChain },
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const transaction = {
|
|
890
|
+
gateStepId,
|
|
891
|
+
gateToken,
|
|
892
|
+
fanoutStepId,
|
|
893
|
+
state: 'prepared',
|
|
894
|
+
baselineTree,
|
|
895
|
+
witnessChain,
|
|
896
|
+
orderedDiffs,
|
|
897
|
+
recovery: { baselineRestores: 0 },
|
|
898
|
+
preparedAt: now(),
|
|
899
|
+
};
|
|
900
|
+
this.journal.mergeTransactions.push(transaction);
|
|
901
|
+
// One durable record contains the entire token/order/diff/witness chain
|
|
902
|
+
// before any target working-tree mutation occurs.
|
|
903
|
+
return transaction;
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** A merge round is "decided" once another party has recorded its gate outcome
|
|
908
|
+
* or rolled its baseline back. applyMerge must never write over that. */
|
|
909
|
+
#assertMergeNotDecided(tx, gateToken) {
|
|
910
|
+
if (tx.gateOutcome !== undefined || tx.state === 'rolled_back') {
|
|
911
|
+
throw new ConsumerMergeDecisionError(
|
|
912
|
+
'MERGE_TRANSACTION_DECIDED',
|
|
913
|
+
`consumer merge ${gateToken} was already resolved (${tx.gateOutcome ?? tx.state}) `
|
|
914
|
+
+ `by another party; refusing to apply/record over a decided round`,
|
|
915
|
+
{ gateToken, gateOutcome: tx.gateOutcome, state: tx.state },
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
#currentTransaction(gateToken) {
|
|
921
|
+
if (existsSync(this.journalPath)) this.journal = this.#reload();
|
|
922
|
+
return this.journal.mergeTransactions.find((entry) => entry.gateToken === gateToken);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async applyMerge(transaction) {
|
|
926
|
+
const gateToken = transaction.gateToken;
|
|
927
|
+
let current = this.#currentTransaction(gateToken);
|
|
928
|
+
if (!current) {
|
|
929
|
+
throw new ConsumerMergeDecisionError(
|
|
930
|
+
'MERGE_TRANSACTION_MISSING', `consumer merge transaction ${gateToken} is missing`, { gateToken },
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
if (current.state === 'blocked') {
|
|
934
|
+
throw new ConsumerMergeDecisionError(
|
|
935
|
+
current.failureCode ?? 'MERGE_TRANSACTION_BLOCKED',
|
|
936
|
+
current.failure ?? 'consumer merge transaction is blocked', { gateToken },
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
this.#assertMergeNotDecided(current, gateToken);
|
|
940
|
+
// The plan (baseline, witness chain, ordered diffs) is immutable after
|
|
941
|
+
// prepareMerge; the mutable lifecycle state is re-read from the fresh journal
|
|
942
|
+
// at every write below so applyMerge never writes over a concurrent decision.
|
|
943
|
+
const { witnessChain, baselineTree, orderedDiffs } = current;
|
|
944
|
+
|
|
945
|
+
if (current.state === 'complete') {
|
|
946
|
+
const completedTree = snapshotWorkingTree(this.targetCwd);
|
|
947
|
+
if (completedTree === witnessChain.at(-1)) return current;
|
|
948
|
+
// A completed journal with a non-final working tree means the process died
|
|
949
|
+
// before gate resolution and the target changed afterward. Re-enter the same
|
|
950
|
+
// prefix/unmatched recovery algorithm instead of trusting state.
|
|
951
|
+
this.#mutate(() => {
|
|
952
|
+
const tx = this.journal.mergeTransactions.find((e) => e.gateToken === gateToken);
|
|
953
|
+
if (!tx) return;
|
|
954
|
+
this.#assertMergeNotDecided(tx, gateToken);
|
|
955
|
+
tx.state = 'prepared';
|
|
956
|
+
tx.recovery ??= { baselineRestores: 0 };
|
|
957
|
+
tx.recovery.completedTreeMismatch = completedTree;
|
|
958
|
+
tx.recovery.completedTreeMismatchAt = now();
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
let currentTree = snapshotWorkingTree(this.targetCwd);
|
|
962
|
+
let prefix = witnessChain.indexOf(currentTree);
|
|
963
|
+
if (prefix < 0) {
|
|
964
|
+
restoreWorkingTree(this.targetCwd, baselineTree);
|
|
965
|
+
this.#mutate(() => {
|
|
966
|
+
const tx = this.journal.mergeTransactions.find((e) => e.gateToken === gateToken);
|
|
967
|
+
if (!tx) return;
|
|
968
|
+
this.#assertMergeNotDecided(tx, gateToken);
|
|
969
|
+
tx.recovery ??= { baselineRestores: 0 };
|
|
970
|
+
tx.recovery.baselineRestores = (tx.recovery.baselineRestores ?? 0) + 1;
|
|
971
|
+
tx.recovery.lastUnmatchedTree = currentTree;
|
|
972
|
+
tx.recovery.lastRestoredAt = now();
|
|
973
|
+
});
|
|
974
|
+
currentTree = snapshotWorkingTree(this.targetCwd);
|
|
975
|
+
if (currentTree !== baselineTree) {
|
|
976
|
+
throw new ConsumerMergeDecisionError(
|
|
977
|
+
'MERGE_BASELINE_RESTORE_FAILED',
|
|
978
|
+
`consumer merge baseline verification failed: expected ${baselineTree}, got ${currentTree}`,
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
prefix = 0;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
for (let orderedIndex = prefix; orderedIndex < orderedDiffs.length; orderedIndex += 1) {
|
|
985
|
+
const ordered = orderedDiffs[orderedIndex];
|
|
986
|
+
try {
|
|
987
|
+
if (typeof this.hooks.insideDiffApply === 'function') {
|
|
988
|
+
await this.hooks.insideDiffApply({
|
|
989
|
+
cwd: this.targetCwd,
|
|
990
|
+
orderedIndex,
|
|
991
|
+
orderedDiff: structuredClone(ordered),
|
|
992
|
+
transaction: structuredClone(current),
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
// Re-read the transaction fresh and refuse if the round was decided
|
|
996
|
+
// (rolled back / gate resolved) by another party — checked AFTER the await
|
|
997
|
+
// above and immediately BEFORE mutating the working tree, so a stale diff is
|
|
998
|
+
// NEVER applied over a decided round (which the gate rollback would then
|
|
999
|
+
// clobber). In-process this is fully sealed: production applyMerge has no
|
|
1000
|
+
// awaits between this check and the apply. Two truly concurrent PROCESSES
|
|
1001
|
+
// retain an inherent TOCTOU window here — there is no OS-level journal lock;
|
|
1002
|
+
// cross-process concurrent merge relies on the run's single-owner assumption,
|
|
1003
|
+
// not a lockfile in this slice.
|
|
1004
|
+
const live = this.#currentTransaction(gateToken);
|
|
1005
|
+
if (live) this.#assertMergeNotDecided(live, gateToken);
|
|
1006
|
+
git(this.targetCwd, ['apply', '--binary', '-'], { input: ordered.diff, timeout: 60_000 });
|
|
1007
|
+
const landedTree = snapshotWorkingTree(this.targetCwd);
|
|
1008
|
+
const expectedTree = witnessChain[orderedIndex + 1];
|
|
1009
|
+
if (landedTree !== expectedTree) {
|
|
1010
|
+
throw new ConsumerArtifactError(
|
|
1011
|
+
'MERGE_PREFIX_WITNESS_MISMATCH',
|
|
1012
|
+
`consumer diff ${orderedIndex} produced ${landedTree}, expected ${expectedTree}`,
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
// An injected crash represents abrupt process death: preserve the partial
|
|
1017
|
+
// target exactly so a fresh loop must exercise unmatched-tree recovery.
|
|
1018
|
+
if (error?.code === 'INJECTED_CONSUMER_CRASH') throw error;
|
|
1019
|
+
// The pre-apply DECIDED guard throws before touching the tree; propagate it
|
|
1020
|
+
// as-is (no baseline restore / block) rather than mislabeling it as an
|
|
1021
|
+
// apply failure over a round another party already owns.
|
|
1022
|
+
if (error instanceof ConsumerMergeDecisionError) throw error;
|
|
1023
|
+
restoreWorkingTree(this.targetCwd, baselineTree);
|
|
1024
|
+
this.#mutate(() => {
|
|
1025
|
+
const tx = this.journal.mergeTransactions.find((e) => e.gateToken === gateToken);
|
|
1026
|
+
// Never overwrite a concurrent decision, even on the error path.
|
|
1027
|
+
if (!tx || tx.gateOutcome !== undefined || tx.state === 'rolled_back') return;
|
|
1028
|
+
tx.state = 'blocked';
|
|
1029
|
+
tx.failure = error.message;
|
|
1030
|
+
tx.recovery ??= { baselineRestores: 0 };
|
|
1031
|
+
tx.recovery.baselineRestores = (tx.recovery.baselineRestores ?? 0) + 1;
|
|
1032
|
+
tx.recovery.lastRestoredAt = now();
|
|
1033
|
+
});
|
|
1034
|
+
throw new ConsumerMergeDecisionError(
|
|
1035
|
+
'MERGE_APPLY_FAILED',
|
|
1036
|
+
`consumer merge apply failed and baseline was restored: ${error.message}`,
|
|
1037
|
+
{ cause: error },
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const finalTree = snapshotWorkingTree(this.targetCwd);
|
|
1043
|
+
const expectedFinal = witnessChain.at(-1);
|
|
1044
|
+
if (finalTree !== expectedFinal) {
|
|
1045
|
+
throw new ConsumerMergeDecisionError(
|
|
1046
|
+
'MERGE_FINAL_WITNESS_MISMATCH',
|
|
1047
|
+
`consumer merge final tree ${finalTree} does not match ${expectedFinal}`,
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
return this.#mutate(() => {
|
|
1051
|
+
const tx = this.journal.mergeTransactions.find((e) => e.gateToken === gateToken);
|
|
1052
|
+
if (!tx) {
|
|
1053
|
+
throw new ConsumerMergeDecisionError(
|
|
1054
|
+
'MERGE_TRANSACTION_MISSING', `consumer merge transaction ${gateToken} is missing`, { gateToken },
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
// STOP rather than record a merge over a round decided since we started.
|
|
1058
|
+
this.#assertMergeNotDecided(tx, gateToken);
|
|
1059
|
+
tx.state = 'complete';
|
|
1060
|
+
tx.completedAt = now();
|
|
1061
|
+
for (const ordered of orderedDiffs) {
|
|
1062
|
+
const issuance = this.journal.issuances.find(
|
|
1063
|
+
(entry) => entry.dispatchToken === ordered.dispatchToken,
|
|
1064
|
+
);
|
|
1065
|
+
if (issuance && issuance.state === 'accepted') {
|
|
1066
|
+
issuance.state = 'merged';
|
|
1067
|
+
issuance.mergedAt = now();
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return tx;
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
restoreMergeBaseline(transaction, audit) {
|
|
1075
|
+
if (!transaction) return undefined;
|
|
1076
|
+
return this.#mutate(() => {
|
|
1077
|
+
// Re-find against the fresh journal — the passed reference may predate a
|
|
1078
|
+
// concurrent instance's writes; we mutate what is CURRENTLY on disk.
|
|
1079
|
+
const tx = this.journal.mergeTransactions.find((entry) => entry.gateToken === transaction.gateToken);
|
|
1080
|
+
if (!tx) return undefined;
|
|
1081
|
+
const wasBlocked = tx.state === 'blocked';
|
|
1082
|
+
restoreWorkingTree(this.targetCwd, tx.baselineTree);
|
|
1083
|
+
tx.state = wasBlocked ? 'blocked' : 'rolled_back';
|
|
1084
|
+
tx.rolledBackAt = now();
|
|
1085
|
+
tx.recovery ??= { baselineRestores: 0 };
|
|
1086
|
+
tx.recovery.baselineRestores = (tx.recovery.baselineRestores ?? 0) + 1;
|
|
1087
|
+
tx.recovery.baselineVerifiedTree = tx.baselineTree;
|
|
1088
|
+
tx.recovery.baselineVerifiedAt = now();
|
|
1089
|
+
// Rolling back un-applies this transaction's diffs from the target, so any
|
|
1090
|
+
// issuance it marked `merged` (a crash between applyMerge and gateResolve) is
|
|
1091
|
+
// no longer merged. Restore merge eligibility (`merged` -> `accepted`) so a
|
|
1092
|
+
// non-re-enumerating repair round can merge it again — BUT reconcile each
|
|
1093
|
+
// restored entry against the CURRENT audit (ground truth). An entry whose
|
|
1094
|
+
// item index no longer exists in the current generation, or whose generation
|
|
1095
|
+
// or accepted token is stale, is superseded, NEVER resurrected as an accepted
|
|
1096
|
+
// diff. Without the audit the entry is only restored if it was mid-flight.
|
|
1097
|
+
for (const ordered of tx.orderedDiffs ?? []) {
|
|
1098
|
+
const issuance = this.journal.issuances.find(
|
|
1099
|
+
(entry) => entry.dispatchToken === ordered.dispatchToken,
|
|
1100
|
+
);
|
|
1101
|
+
if (!issuance || issuance.state !== 'merged') continue;
|
|
1102
|
+
const fanoutItems = audit?.steps?.[issuance.fanoutStepId]?.fanout?.items;
|
|
1103
|
+
const item = Array.isArray(fanoutItems) ? fanoutItems[issuance.itemIndex] : undefined;
|
|
1104
|
+
const stale = Array.isArray(fanoutItems) && (
|
|
1105
|
+
!item
|
|
1106
|
+
|| item.generation !== issuance.generation
|
|
1107
|
+
|| (item.status === 'succeeded' && item.acceptedDispatchToken !== issuance.dispatchToken)
|
|
1108
|
+
|| (typeof item.dispatchToken === 'string' && item.dispatchToken !== issuance.dispatchToken)
|
|
1109
|
+
);
|
|
1110
|
+
if (stale) {
|
|
1111
|
+
issuance.state = 'superseded';
|
|
1112
|
+
issuance.supersededAt = now();
|
|
1113
|
+
delete issuance.mergedAt;
|
|
1114
|
+
} else {
|
|
1115
|
+
issuance.state = 'accepted';
|
|
1116
|
+
delete issuance.mergedAt;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return tx;
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
markGateResolved(transaction, outcome) {
|
|
1124
|
+
if (!transaction) return undefined;
|
|
1125
|
+
return this.#mutate(() => {
|
|
1126
|
+
const tx = this.journal.mergeTransactions.find((entry) => entry.gateToken === transaction.gateToken);
|
|
1127
|
+
if (!tx) return undefined;
|
|
1128
|
+
tx.gateOutcome = outcome;
|
|
1129
|
+
tx.gateResolvedAt = now();
|
|
1130
|
+
// Supersession is driven by re-enumeration, NOT by the gate outcome (see F1).
|
|
1131
|
+
return tx;
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Recovery-only: record that a completed transaction's gate advanced (approve)
|
|
1136
|
+
* without re-running its merge, expressed as a change to the fresh journal. */
|
|
1137
|
+
markGateAdvanced({ gateToken, outcome, at }) {
|
|
1138
|
+
return this.#mutate(() => {
|
|
1139
|
+
const tx = this.journal.mergeTransactions.find((entry) => entry.gateToken === gateToken);
|
|
1140
|
+
if (!tx) return undefined;
|
|
1141
|
+
tx.gateOutcome = outcome;
|
|
1142
|
+
tx.gateResolvedAt ??= at ?? now();
|
|
1143
|
+
return tx;
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
cleanupWorktrees(reason) {
|
|
1148
|
+
this.#mutate(() => {
|
|
1149
|
+
for (const record of this.journal.worktrees) {
|
|
1150
|
+
if (record.cleanedAt) continue;
|
|
1151
|
+
if (existsSync(record.path)) {
|
|
1152
|
+
try {
|
|
1153
|
+
git(this.targetCwd, ['worktree', 'remove', '--force', record.path], { timeout: 60_000 });
|
|
1154
|
+
} catch {
|
|
1155
|
+
rmSync(record.path, { recursive: true, force: true });
|
|
1156
|
+
try { git(this.targetCwd, ['worktree', 'prune']); } catch { /* best effort */ }
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
record.cleanedAt = now();
|
|
1160
|
+
record.cleanupReason = reason;
|
|
1161
|
+
}
|
|
1162
|
+
for (const issuance of this.journal.issuances) {
|
|
1163
|
+
if (typeof issuance.diff === 'string') {
|
|
1164
|
+
issuance.diff = null;
|
|
1165
|
+
issuance.diffDroppedAt = now();
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
for (const transaction of this.journal.mergeTransactions) {
|
|
1169
|
+
for (const ordered of transaction.orderedDiffs ?? []) {
|
|
1170
|
+
if (typeof ordered.diff === 'string') ordered.diff = null;
|
|
1171
|
+
}
|
|
1172
|
+
if (transaction.orderedDiffs?.length > 0 && !transaction.diffPayloadsDroppedAt) {
|
|
1173
|
+
transaction.diffPayloadsDroppedAt = now();
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Resume-time revision guard. Before Compose trusts its (mutable) local pipeline
|
|
1182
|
+
* spec to detect final stages or discover merge-gate ownership, verify the spec
|
|
1183
|
+
* still describes the run being resumed. A spec edited between crash and resume
|
|
1184
|
+
* could otherwise approve the fanout's merge gate WITHOUT applying the accepted
|
|
1185
|
+
* diffs, or exit the pump with diffs stranded.
|
|
1186
|
+
*
|
|
1187
|
+
* Fails loudly (never reconciles, never resolves a gate) on any mismatch:
|
|
1188
|
+
* - the journal holds work but is MISSING its revision pins (fail-closed: a
|
|
1189
|
+
* journal that recorded issuances/worktrees/merges without pins cannot be
|
|
1190
|
+
* verified against the local spec, so it must not be trusted);
|
|
1191
|
+
* - the local-spec fingerprint differs from the one pinned at run start;
|
|
1192
|
+
* - the engine's resume-response revision differs from the journaled revision;
|
|
1193
|
+
* - a journal entry carries a revision other than the run's.
|
|
1194
|
+
*
|
|
1195
|
+
* Returns null (no-op) when the run has no consumer journal, or when the journal
|
|
1196
|
+
* is empty AND unpinned — a pre-first-issuance artifact with nothing staked on
|
|
1197
|
+
* it, safe to let the run recreate and re-pin (new code pins at creation, so this
|
|
1198
|
+
* only covers legacy journals).
|
|
1199
|
+
*/
|
|
1200
|
+
export function verifyConsumerRunRevision({
|
|
1201
|
+
runId,
|
|
1202
|
+
targetCwd,
|
|
1203
|
+
artifactRoot,
|
|
1204
|
+
specDigest,
|
|
1205
|
+
resumeRevisionDigest,
|
|
1206
|
+
}) {
|
|
1207
|
+
if (!runId || !existsSync(journalLocation({ runId, targetCwd, artifactRoot }))) return null;
|
|
1208
|
+
const { journal } = new ConsumerFanoutArtifacts({ runId, targetCwd, artifactRoot });
|
|
1209
|
+
|
|
1210
|
+
const fullyPinned = Boolean(journal.revisionDigest) && Boolean(journal.specDigest);
|
|
1211
|
+
if (!fullyPinned) {
|
|
1212
|
+
const hasWork = journal.issuances.length > 0
|
|
1213
|
+
|| journal.worktrees.length > 0
|
|
1214
|
+
|| journal.mergeTransactions.length > 0;
|
|
1215
|
+
if (hasWork) {
|
|
1216
|
+
throw new ConsumerArtifactError(
|
|
1217
|
+
'CONSUMER_RUN_REVISION_MISMATCH',
|
|
1218
|
+
`consumer journal for run ${runId} recorded work but is missing its revision pins `
|
|
1219
|
+
+ `(revisionDigest=${journal.revisionDigest ?? 'null'}, specDigest=${journal.specDigest ?? 'null'}) `
|
|
1220
|
+
+ `- cannot verify the local spec still describes it`,
|
|
1221
|
+
{ runId, revisionDigest: journal.revisionDigest, specDigest: journal.specDigest, hasWork: true },
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
return null;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
if (journal.specDigest && specDigest && journal.specDigest !== specDigest) {
|
|
1228
|
+
throw new ConsumerArtifactError(
|
|
1229
|
+
'CONSUMER_RUN_REVISION_MISMATCH',
|
|
1230
|
+
`consumer pipeline spec changed since run ${runId} started: `
|
|
1231
|
+
+ `recorded specDigest ${journal.specDigest}, current ${specDigest}`
|
|
1232
|
+
+ (journal.revisionDigest ? ` (engine revision ${journal.revisionDigest})` : ''),
|
|
1233
|
+
{
|
|
1234
|
+
runId,
|
|
1235
|
+
recordedSpecDigest: journal.specDigest,
|
|
1236
|
+
currentSpecDigest: specDigest,
|
|
1237
|
+
revisionDigest: journal.revisionDigest,
|
|
1238
|
+
},
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
if (journal.revisionDigest && resumeRevisionDigest && journal.revisionDigest !== resumeRevisionDigest) {
|
|
1243
|
+
throw new ConsumerArtifactError(
|
|
1244
|
+
'CONSUMER_RUN_REVISION_MISMATCH',
|
|
1245
|
+
`consumer engine revision changed since run ${runId} started: `
|
|
1246
|
+
+ `journaled ${journal.revisionDigest}, resume ${resumeRevisionDigest}`,
|
|
1247
|
+
{ runId, journaledRevisionDigest: journal.revisionDigest, resumeRevisionDigest },
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const stray = journal.issuances.find(
|
|
1252
|
+
(entry) => entry.revisionDigest
|
|
1253
|
+
&& journal.revisionDigest
|
|
1254
|
+
&& entry.revisionDigest !== journal.revisionDigest,
|
|
1255
|
+
);
|
|
1256
|
+
if (stray) {
|
|
1257
|
+
throw new ConsumerArtifactError(
|
|
1258
|
+
'CONSUMER_RUN_REVISION_MISMATCH',
|
|
1259
|
+
`consumer journal entry ${stray.dispatchToken} carries revision ${stray.revisionDigest}, `
|
|
1260
|
+
+ `not the run revision ${journal.revisionDigest}`,
|
|
1261
|
+
{ runId, entryRevisionDigest: stray.revisionDigest, revisionDigest: journal.revisionDigest },
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
return journal;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Resume-time cleanup for a process that died after gateResolve durably
|
|
1270
|
+
* advanced the engine but before Compose marked/released local artifacts.
|
|
1271
|
+
* Returns false without creating anything when the run has no consumer journal.
|
|
1272
|
+
*/
|
|
1273
|
+
export function recoverAdvancedConsumerArtifacts({
|
|
1274
|
+
runId,
|
|
1275
|
+
targetCwd,
|
|
1276
|
+
artifactRoot,
|
|
1277
|
+
audit,
|
|
1278
|
+
}) {
|
|
1279
|
+
if (!runId || !existsSync(journalLocation({ runId, targetCwd, artifactRoot }))) return false;
|
|
1280
|
+
const artifacts = new ConsumerFanoutArtifacts({ runId, targetCwd, artifactRoot });
|
|
1281
|
+
let recoveredDecision = false;
|
|
1282
|
+
let approvedAdvanced = false;
|
|
1283
|
+
const gateEventOffsets = new Map();
|
|
1284
|
+
const gateEvents = (audit?.events ?? []).filter((event) => event?.type === 'gate_resolved');
|
|
1285
|
+
const terminal = ['completed', 'failed', 'budget_exhausted', 'killed', 'cancelled'].includes(audit?.status);
|
|
1286
|
+
for (const transaction of artifacts.journal.mergeTransactions) {
|
|
1287
|
+
const gateState = audit?.steps?.[transaction.gateStepId];
|
|
1288
|
+
const matchingGateEvents = gateEvents.filter(
|
|
1289
|
+
(event) => event.stepId === transaction.gateStepId,
|
|
1290
|
+
);
|
|
1291
|
+
const eventOffset = gateEventOffsets.get(transaction.gateStepId) ?? 0;
|
|
1292
|
+
const resolvedEvent = matchingGateEvents[eventOffset];
|
|
1293
|
+
gateEventOffsets.set(transaction.gateStepId, eventOffset + 1);
|
|
1294
|
+
const resolvedOutcome = transaction.gateOutcome ?? resolvedEvent?.detail?.decision;
|
|
1295
|
+
const approvedComplete = transaction.state === 'complete' && resolvedOutcome === 'approve';
|
|
1296
|
+
|
|
1297
|
+
if (approvedComplete && gateState?.status !== 'waiting_gate') {
|
|
1298
|
+
artifacts.markGateAdvanced({ gateToken: transaction.gateToken, outcome: resolvedOutcome, at: resolvedEvent?.at });
|
|
1299
|
+
recoveredDecision = true;
|
|
1300
|
+
approvedAdvanced = true;
|
|
1301
|
+
} else if (!transaction.gateOutcome && (terminal || (resolvedOutcome && resolvedOutcome !== 'approve'))) {
|
|
1302
|
+
// Recovery may only roll back an UNRESOLVED transaction — the round that was
|
|
1303
|
+
// interrupted mid-finalization. A transaction that already carries a
|
|
1304
|
+
// gateOutcome was resolved in a PRIOR round; its rollback/merge is durable
|
|
1305
|
+
// history, and re-restoring its stale baseline here would clobber a later
|
|
1306
|
+
// approved merge (the current target). restoreMergeBaseline reconciles any
|
|
1307
|
+
// restored evidence against the current audit before it is trusted.
|
|
1308
|
+
artifacts.restoreMergeBaseline(transaction, audit);
|
|
1309
|
+
if (resolvedOutcome) artifacts.markGateResolved(transaction, resolvedOutcome);
|
|
1310
|
+
recoveredDecision = recoveredDecision || Boolean(resolvedOutcome);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
if (approvedAdvanced || terminal) {
|
|
1314
|
+
artifacts.cleanupWorktrees(approvedAdvanced ? 'merge gate advanced before restart' : 'run terminalized before restart');
|
|
1315
|
+
}
|
|
1316
|
+
return recoveredDecision || terminal;
|
|
1317
|
+
}
|