dsh-plugin-worktrees 0.1.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.
@@ -0,0 +1,832 @@
1
+ /**
2
+ * MergeQueue — the serial integration engine (DESIGN §6, the core module).
3
+ *
4
+ * Ported from task-weaver `packages/workspaces/src/merge-queue.ts` +
5
+ * `change-collector.ts` (collectGit), with the DESIGN §6.1 persistence
6
+ * rewrite: ServiceContext/repos/leases/transactions are replaced by an
7
+ * injected StateStore (single writer, atomic flush) and a per-branch
8
+ * in-process promise chain (the "lease"). Everything else — the one-active
9
+ * invariant, the conflict-retention semantics, the command state machine —
10
+ * is carried over line-faithfully.
11
+ *
12
+ * INVARIANTS (source merge-queue.ts L16-27, verbatim semantics):
13
+ * - ONE active job per repoKey + integrationBranch (queued / applying /
14
+ * conflicted). Enforced by `findActiveJob` + the chain.
15
+ * - SERIAL apply — never two concurrent applies on the same integration
16
+ * branch (`chains` Map tail-chaining).
17
+ * - A conflicted job PRESERVES the integration worktree + source branch
18
+ * (no force-delete) and HOLDS the branch until `resolve`/`retry`.
19
+ * - No auto-push (the GitPort face has no push at all).
20
+ *
21
+ * Two DSH-specific mechanisms the source did not need (documented because
22
+ * they are the only non-mechanical additions):
23
+ *
24
+ * 1. `merge --no-ff` instead of cherry-pick (DESIGN §6.3 D7): a DSH
25
+ * subagent may make 1..N commits in its worktree; a merge commit
26
+ * integrates all of them losslessly while keeping one reviewable
27
+ * integration commit per task. Conflict detection is identical.
28
+ *
29
+ * 2. Stale-hold release during provisioning: git refuses to check one
30
+ * branch out in two worktrees ("'x' is already used by worktree at …").
31
+ * task-weaver's temp-dir integration worktrees block invisibly there
32
+ * (retry-after-conflict would fail provisioning); DSH keeps deterministic
33
+ * paths under `<worktreeRoot>/<repoKey>/.integration/`, so provisioning
34
+ * can recognise and remove a STALE hold of the same branch inside OUR
35
+ * namespace when no live `conflicted` job references it. Operator
36
+ * worktrees outside the namespace are never touched (the provisioning
37
+ * failure surfaces as `failed` instead).
38
+ */
39
+
40
+ import { randomBytes } from 'node:crypto'
41
+ import { existsSync, mkdirSync, realpathSync } from 'node:fs'
42
+ import path from 'node:path'
43
+ import { integrationWorktreePath, sanitizeBranch } from './naming.js'
44
+ import { ACTIVE_JOB_STATES } from './state-store.js'
45
+
46
+ /** Truncation bound for git stderr / error details (DESIGN §4.3: 600 chars). */
47
+ const DETAIL_MAX = 600
48
+
49
+ /** Clamp a detail string to DETAIL_MAX chars with an explicit marker. */
50
+ function clamp(text, max = DETAIL_MAX) {
51
+ const value = String(text ?? '')
52
+ if (value.length <= max) return value
53
+ return `${value.slice(0, max)}…[truncated ${value.length - max} chars]`
54
+ }
55
+
56
+ /** Error message of an unknown throw (spawn errors, strings, …). */
57
+ function errorText(error) {
58
+ return error instanceof Error ? error.message : String(error ?? '')
59
+ }
60
+
61
+ /** realpath() that yields null instead of throwing (missing paths probe). */
62
+ function realPathBestEffort(target) {
63
+ try {
64
+ return realpathSync(target)
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ /** Fresh merge-job id: `mgj_` + 8 hex chars (crypto.randomBytes). */
71
+ function newJobId() {
72
+ return `mgj_${randomBytes(4).toString('hex')}`
73
+ }
74
+
75
+ /**
76
+ * Vet one integration-branch name with `git check-ref-format --branch`
77
+ * (audit P1-B): the name reaches `git branch <name> <startPoint>`
78
+ * (ensureBranch) and `git merge --no-ff … -- <name>` at apply time — a
79
+ * flag-shaped value (`-m`, `--force`) would land in flag position. Every
80
+ * entry point that accepts a caller-supplied integration branch (the
81
+ * worktree_merge override, the DAG four-key enqueue, applyOne's choke
82
+ * point) funnels through this one guard.
83
+ *
84
+ * @throws {MergeError} code `invalid_integration_branch`, message naming
85
+ * the offending value and the legal-branch-name rules.
86
+ */
87
+ export async function assertValidIntegrationBranch(git, repoRoot, integrationBranch) {
88
+ if (typeof integrationBranch !== 'string' || integrationBranch.length === 0) {
89
+ throw new MergeError(
90
+ 'invalid_integration_branch',
91
+ 'integration branch must be a non-empty string',
92
+ )
93
+ }
94
+ let valid = false
95
+ try {
96
+ valid = await git.validateBranch(repoRoot, integrationBranch)
97
+ } catch (error) {
98
+ throw new MergeError(
99
+ 'invalid_integration_branch',
100
+ `cannot vet integration branch ${JSON.stringify(integrationBranch)} (${errorText(error)})`,
101
+ )
102
+ }
103
+ if (!valid) {
104
+ throw new MergeError(
105
+ 'invalid_integration_branch',
106
+ `integration branch name ${JSON.stringify(integrationBranch)} is rejected by ` +
107
+ 'git check-ref-format --branch (legal branch names: no leading "-" or "-", no "..", ' +
108
+ 'no ASCII control characters or spaces, no trailing ".lock", no "@{"/"@"-only, ' +
109
+ 'no ref-component starting with "."; slashes are allowed, e.g. "dsh-wt/integration/x")',
110
+ )
111
+ }
112
+ return true
113
+ }
114
+
115
+ /**
116
+ * Structured merge-queue error. The tool layer matches `code`.
117
+ *
118
+ * NOTE: `active_job_exists` is deliberately NOT one of these codes — a job
119
+ * holding the branch is an expected business state, represented by
120
+ * `applyUntilBlockedOrEmpty` returning `{ blockedBy }` (source step 1 maps
121
+ * it to an error only because task-weaver's callers are internal).
122
+ */
123
+ export class MergeError extends Error {
124
+ /**
125
+ * @param {string} code machine-matchable code (`merge_job_not_found`,
126
+ * `invalid_job_state`, `git_operation_failed`, `invalid_params`).
127
+ * @param {string} message human-readable detail.
128
+ */
129
+ constructor(code, message) {
130
+ super(message)
131
+ this.name = 'MergeError'
132
+ this.code = code
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Create a MergeQueue.
138
+ *
139
+ * @param {{
140
+ * git: import('./git-port.js').NodeGitPort,
141
+ * store: ReturnType<import('./state-store.js').createStateStore>,
142
+ * config: {
143
+ * worktreeRoot: string,
144
+ * autoCollect?: boolean,
145
+ * mergeTimeoutMs?: number,
146
+ * retainJobHistory?: number,
147
+ * onError?: (message: string) => void,
148
+ * },
149
+ * }} deps
150
+ * `git` / `store` are injected (tests + the future dag-orchestrator seam,
151
+ * DESIGN §10). `config` carries RESOLVED values (defaults live in the
152
+ * config layer; only `autoCollect !== false` and a `retainJobHistory`
153
+ * numeric guard are applied here defensively). `mergeTimeoutMs` is the
154
+ * budget for applyOne's merge call (step 7) — applied per-call via
155
+ * mergeNoFf's timeoutMs override, separate from the port-level
156
+ * gitTimeoutMs (default 15s) so slow big-repo merges are not killed
157
+ * early.
158
+ * `onError` receives swallowed background-drain failures.
159
+ * @returns {{
160
+ * collect: (worktree: object, commitMessage?: string) => Promise<{state: 'ok'|'no_changes'|'dirty_not_collected', sourceHead: string}>,
161
+ * enqueue: (params: object) => object,
162
+ * drain: (repoKey: string, integrationBranch: string) => Promise<{drained?: true, blockedBy?: object}>,
163
+ * applyUntilBlockedOrEmpty: (repoKey: string, integrationBranch: string) => Promise<{drained?: true, blockedBy?: object}>,
164
+ * applyOne: (job: object) => Promise<{state: string, [k: string]: unknown}>,
165
+ * cancel: (params: {jobId: string}) => {jobId: string, state: string},
166
+ * retry: (params: {jobId: string}) => {jobId: string, state: string},
167
+ * resolve: (params: {jobId: string}) => {jobId: string, state: string},
168
+ * listJobs: (filters?: {repoKey?: string, integrationBranch?: string}) => object[],
169
+ * branchHolders: () => object[],
170
+ * }}
171
+ */
172
+ export function createMergeQueue({ git, store, config } = {}) {
173
+ if (!git || typeof git.mergeNoFf !== 'function') {
174
+ throw new Error('merge-queue: a GitPort must be injected (missing mergeNoFf)')
175
+ }
176
+ if (!store || typeof store.upsertJob !== 'function') {
177
+ throw new Error('merge-queue: a StateStore must be injected (missing upsertJob)')
178
+ }
179
+ if (!config || typeof config.worktreeRoot !== 'string' || config.worktreeRoot.length === 0) {
180
+ throw new Error('merge-queue: config.worktreeRoot (resolved) is required')
181
+ }
182
+ const autoCollect = config.autoCollect !== false
183
+ const retainJobHistory =
184
+ Number.isFinite(config.retainJobHistory) && config.retainJobHistory > 0
185
+ ? Math.floor(config.retainJobHistory)
186
+ : 200
187
+ const onError = typeof config.onError === 'function' ? config.onError : null
188
+ // DESIGN §8.1 `mergeTimeoutMs`: the budget for the ONE long git call in
189
+ // the apply pipeline (`git merge --no-ff`), separated from the port's
190
+ // general per-command `gitTimeoutMs` (default 15s) — a merge on a big
191
+ // repo legitimately needs more. Applied per-call in applyOne step 7 via
192
+ // mergeNoFf's timeoutMs override (runGit SIGKILLs on expiry; a killed
193
+ // merge surfaces as a non-zero exit → the normal hard-failure path).
194
+ const mergeTimeoutMs =
195
+ Number.isFinite(config.mergeTimeoutMs) && config.mergeTimeoutMs > 0
196
+ ? Math.floor(config.mergeTimeoutMs)
197
+ : null
198
+
199
+ /**
200
+ * Per-branch serial chains: `Map<repoKey|integrationBranch, Promise>`.
201
+ * This is the DESIGN §6.1 replacement for task-weaver's exclusive
202
+ * ResourceLease — inside one process a tail-chained promise IS strict
203
+ * serialisation, and every job-state mutation happens inside the chain
204
+ * (the "CAS is unnecessary" invariant).
205
+ * @type {Map<string, Promise>}
206
+ */
207
+ const chains = new Map()
208
+
209
+ const chainKey = (repoKey, integrationBranch) => `${repoKey}|${integrationBranch}`
210
+
211
+ /** Flush with the terminal-job retention bound (DESIGN §7.1 prune). */
212
+ function flush() {
213
+ store.pruneTerminalJobs(retainJobHistory)
214
+ store.persist()
215
+ }
216
+
217
+ /** Default merge commit message (DESIGN §5.6): integrate <task> (<short head>). */
218
+ function defaultIntegrateMessage(job) {
219
+ const task = store.worktrees[job.worktreeId]?.task ?? job.worktreeId ?? job.id
220
+ const short = String(job.sourceHead ?? '').slice(0, 8)
221
+ return `dsh-worktrees: integrate ${task} (${short})`
222
+ }
223
+
224
+ /**
225
+ * Mirror a job transition onto its worktree record (DESIGN §5.2.2 worktree
226
+ * state machine: merging → merged/conflicted, or back to active on a hard
227
+ * failure). Missing worktree records (dag-seeded jobs) are skipped.
228
+ */
229
+ function markWorktree(job, state, extra = {}) {
230
+ if (!job.worktreeId) return
231
+ const record = store.worktrees[job.worktreeId]
232
+ if (!record) return
233
+ store.upsertWorktree({ ...record, state, mergeJobId: job.id, ...extra })
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // collect — change-collector `collectGit` semantics (source L162-222),
238
+ // with the DSH §5.6 downgrade: a clean tree at base is `no_changes`, a
239
+ // NORMAL outcome, not an attempt failure.
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /**
243
+ * Collect uncommitted changes in a task worktree and resolve its head.
244
+ *
245
+ * 1. `git.status(path)` — dirty AND autoCollect → `git.commitAll` (a
246
+ * commit failure throws `git_operation_failed` with clamped stderr);
247
+ * 2. `sourceHead = git.resolveHead(path)`;
248
+ * 3. CLEAN tree whose merge-base with `baseCommit` IS `sourceHead` →
249
+ * `{ state: 'no_changes', sourceHead }` (nothing to integrate);
250
+ * 4. otherwise `{ state: 'ok', sourceHead }`.
251
+ *
252
+ * A DIRTY tree with autoCollect=false returns the explicit terminal
253
+ * `{ state: 'dirty_not_collected', sourceHead }` (audit P2): proceeding
254
+ * with `ok` would merge the OLD head and report `succeeded` while the
255
+ * uncommitted files were never integrated — the least misunderstandable
256
+ * contract is a loud stop. The caller maps it to a failed merge job /
257
+ * return state with the remediation hint (commit the work, or re-enable
258
+ * autoCollect).
259
+ * An unresolvable base (mergeBase throws) fails open toward `ok` — the
260
+ * merge itself will surface any real problem.
261
+ *
262
+ * @param {{path: string, repoRoot?: string, baseCommit?: string, task?: string, id?: string}} worktree
263
+ * @param {string} [commitMessage] defaults to `dsh-worktrees: collect <task>`.
264
+ */
265
+ async function collect(worktree, commitMessage) {
266
+ if (!worktree || typeof worktree.path !== 'string' || worktree.path.length === 0) {
267
+ throw new MergeError('invalid_params', 'collect requires a worktree record with a path')
268
+ }
269
+ const entries = await git.status(worktree.path)
270
+ let collected = false
271
+ if (entries.length > 0 && autoCollect) {
272
+ const message =
273
+ commitMessage ?? `dsh-worktrees: collect ${worktree.task ?? worktree.id ?? 'worktree'}`
274
+ try {
275
+ await git.commitAll(worktree.path, message)
276
+ collected = true
277
+ } catch (error) {
278
+ throw new MergeError(
279
+ 'git_operation_failed',
280
+ `auto-collect commit failed in ${worktree.path}: ${clamp(errorText(error))}`,
281
+ )
282
+ }
283
+ }
284
+ const sourceHead = (await git.resolveHead(worktree.path)).commit
285
+ // Dirty tree + autoCollect=false: STOP loudly (see the JSDoc above).
286
+ if (entries.length > 0 && !collected) {
287
+ return { state: 'dirty_not_collected', sourceHead }
288
+ }
289
+ if (!collected && entries.length === 0 && worktree.baseCommit) {
290
+ let baseOf = null
291
+ try {
292
+ baseOf = await git.mergeBase(
293
+ worktree.repoRoot ?? worktree.path,
294
+ sourceHead,
295
+ worktree.baseCommit,
296
+ )
297
+ } catch {
298
+ // Unresolvable base (unrelated history / dangling oid) — fail open.
299
+ }
300
+ if (baseOf === sourceHead) return { state: 'no_changes', sourceHead }
301
+ }
302
+ return { state: 'ok', sourceHead }
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // enqueue (DESIGN §6.2; source L205-251)
307
+ // ---------------------------------------------------------------------------
308
+
309
+ /**
310
+ * Enqueue one merge job: id `mgj_<8hex>`, `orderIndex = nextOrderIndex`
311
+ * (monotonic per branch), state `queued`, origin default `'tool'`.
312
+ * Registers `store.repos[repoKey]` so `applyOne` can find the repo root
313
+ * (the crash-reconcile enumeration source, §5.2.2). Persisted immediately.
314
+ * Returns the stored job record.
315
+ *
316
+ * SYNCHRONOUS, seven-key form — the tool layer's contract
317
+ * (worktree_merge awaits nothing here; git facts were resolved by its
318
+ * collect step). The DAG four-key dialect (git facts resolved
319
+ * server-side from the worktree record + check-ref-format vetting +
320
+ * idempotence-while-active) is layered on TOP of this by the engine
321
+ * facade (lib/engine-face.js) — the same "wrap, don't change" rule the
322
+ * drain projection follows. Defense in depth for BOTH dialects lives in
323
+ * applyOne's check-ref-format choke point: no job record, however it was
324
+ * seeded, reaches `git branch <name> <startPoint>` unvetted.
325
+ */
326
+ function enqueue(params = {}) {
327
+ const { repoKey, repoRoot, integrationBranch, worktreeId, sourceBranch, sourceHead } = params
328
+ const required = { repoKey, repoRoot, integrationBranch, worktreeId, sourceBranch, sourceHead }
329
+ for (const [name, value] of Object.entries(required)) {
330
+ if (typeof value !== 'string' || value.length === 0) {
331
+ throw new MergeError('invalid_params', `enqueue requires a non-empty string ${name}`)
332
+ }
333
+ }
334
+ const { message, origin, correlationId } = params
335
+ const id = newJobId()
336
+ const orderIndex = store.nextOrderIndex(repoKey, integrationBranch)
337
+ store.repos[repoKey] = { root: repoRoot, lastSeenAt: Date.now() }
338
+ const job = store.upsertJob({
339
+ id,
340
+ repoKey,
341
+ integrationBranch,
342
+ worktreeId,
343
+ sourceBranch,
344
+ sourceHead,
345
+ orderIndex,
346
+ state: 'queued',
347
+ ...(message !== undefined && message !== null ? { message } : {}),
348
+ origin: origin === 'dag' ? 'dag' : 'tool',
349
+ ...(correlationId !== undefined && correlationId !== null ? { correlationId } : {}),
350
+ })
351
+ flush()
352
+ return job
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Provisioning helpers (step 5)
357
+ // ---------------------------------------------------------------------------
358
+
359
+ /** The plugin's integration namespace dir for one repo. */
360
+ function integrationNamespace(repoKey) {
361
+ return path.join(config.worktreeRoot, repoKey, '.integration')
362
+ }
363
+
364
+ /**
365
+ * Deterministic integration worktree path for one apply attempt. The base
366
+ * path is jobId-derived (crash reconcile can walk it back to the job); a
367
+ * retry whose old dir still occupies the base path gets an `-rN` suffix
368
+ * (git requires the leaf to not exist — source L633-635).
369
+ */
370
+ function nextIntegrationPath(job) {
371
+ const token = sanitizeBranch(job.integrationBranch)
372
+ const base = integrationWorktreePath(config.worktreeRoot, job.repoKey, token, job.id)
373
+ if (!existsSync(base)) return base
374
+ for (let n = 2; n <= 64; n += 1) {
375
+ const candidate = integrationWorktreePath(
376
+ config.worktreeRoot,
377
+ job.repoKey,
378
+ token,
379
+ `${job.id}-r${n}`,
380
+ )
381
+ if (!existsSync(candidate)) return candidate
382
+ }
383
+ return integrationWorktreePath(
384
+ config.worktreeRoot,
385
+ job.repoKey,
386
+ token,
387
+ `${job.id}-${Date.now().toString(36)}`,
388
+ )
389
+ }
390
+
391
+ /**
392
+ * Remove STALE holds of `job.integrationBranch` inside OUR integration
393
+ * namespace: git allows a branch in only one worktree, so a leftover
394
+ * registration (crashed apply, or the retained scene of a job that has
395
+ * since been resolved/retried) would make `git worktree add` fail.
396
+ *
397
+ * Guards (all must hold — operator worktrees are NEVER touched):
398
+ * - the live entry's checked-out branch equals this integration branch;
399
+ * - the entry path sits inside `<worktreeRoot>/<repoKey>/.integration`
400
+ * (realpath-compared: git reports realpaths, worktreeRoot may be a
401
+ * symlinked prefix e.g. /var → /private/var on macOS);
402
+ * - its leaf name carries this branch's sanitized prefix;
403
+ * - it is NOT the retained scene of a live `conflicted` job on this
404
+ * branch (those keep the branch by design until resolve/retry).
405
+ */
406
+ async function releaseStaleIntegrationHolds(repoRoot, job) {
407
+ let entries
408
+ try {
409
+ entries = await git.listWorktrees(repoRoot)
410
+ } catch {
411
+ return // probe failure → let addWorktreeAt surface the real error
412
+ }
413
+ const nsReal = realPathBestEffort(integrationNamespace(job.repoKey))
414
+ if (nsReal === null) return // namespace absent → nothing stale can exist
415
+ const prefix = `${sanitizeBranch(job.integrationBranch)}-`
416
+ const retained = new Set()
417
+ for (const other of Object.values(store.jobs)) {
418
+ if (
419
+ other.state === 'conflicted' &&
420
+ other.repoKey === job.repoKey &&
421
+ other.integrationBranch === job.integrationBranch &&
422
+ other.integrationWorktree
423
+ ) {
424
+ retained.add(realPathBestEffort(other.integrationWorktree) ?? other.integrationWorktree)
425
+ }
426
+ }
427
+ for (const entry of entries) {
428
+ if (entry.branch !== job.integrationBranch) continue
429
+ const entryReal = realPathBestEffort(entry.path)
430
+ if (entryReal === null) continue
431
+ if (path.dirname(entryReal) !== nsReal) continue
432
+ if (!path.basename(entryReal).startsWith(prefix)) continue
433
+ if (retained.has(entryReal)) continue
434
+ await git.removeWorktree(repoRoot, entry.path).catch(() => {})
435
+ }
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // applyOne — the ten steps (DESIGN §6.3 table, source #applyNextBody
440
+ // L495-845). Step numbers in comments reference the DESIGN table rows.
441
+ // ---------------------------------------------------------------------------
442
+
443
+ /**
444
+ * Mark a job terminally `failed` (source markFailed L904-947 — the
445
+ * "queued or applying, either way it is failed now" transition) and return
446
+ * the failed outcome.
447
+ */
448
+ function markFailedOutcome(job, headline, detail) {
449
+ const error = clamp(detail ? `${headline}: ${detail}` : headline)
450
+ const fresh = store.jobs[job.id] ?? job
451
+ store.upsertJob({ ...fresh, state: 'failed', error })
452
+ markWorktree(job, 'active')
453
+ flush()
454
+ return { state: 'failed', error, job: store.jobs[job.id] }
455
+ }
456
+
457
+ /**
458
+ * Apply ONE queued job on its integration branch (must be called inside
459
+ * the branch's serial chain). Never throws for git-level problems — those
460
+ * are classified into `conflicted` (scene retained) or `failed` (scene
461
+ * cleaned) outcomes, exactly like the source.
462
+ */
463
+ async function applyOne(inputJob) {
464
+ const jobId = inputJob.id
465
+ const job = store.jobs[jobId] ?? inputJob
466
+ const { repoKey, integrationBranch } = job
467
+
468
+ // Step 1 (source L506-520) — double-check the branch holder. A queued
469
+ // active job is the one we would apply (fall through); applying/conflicted
470
+ // means the branch is busy.
471
+ const active = store.findActiveJob(repoKey, integrationBranch)
472
+ if (active !== undefined && active.state !== 'queued') {
473
+ return { state: 'blocked', blockedBy: active }
474
+ }
475
+ if (job.state !== 'queued') {
476
+ // The record moved between the caller's read and now (defensive; the
477
+ // chain makes this unreachable in practice).
478
+ return { state: 'blocked', blockedBy: job }
479
+ }
480
+
481
+ // Step 3 (source artifact validation L535-579) — its DSH equivalent ran
482
+ // before enqueue (§5.6 step 2 collect produced sourceHead). The
483
+ // markFailed path is kept for a missing/foreign record.
484
+ if (!job.sourceBranch || !job.sourceHead) {
485
+ return markFailedOutcome(
486
+ job,
487
+ 'source head missing',
488
+ `job ${jobId} carries no sourceBranch/sourceHead (collect before enqueue)`,
489
+ )
490
+ }
491
+ const repoRoot = store.repos[repoKey]?.root
492
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
493
+ return markFailedOutcome(
494
+ job,
495
+ 'repo root unknown',
496
+ `no repos entry for repoKey ${repoKey}; enqueue registers it`,
497
+ )
498
+ }
499
+
500
+ // P1-B choke point (defense in depth): a job record could carry a
501
+ // poisoned integrationBranch from a hand-edited / pre-fix state.json.
502
+ // Before ANY git call that puts the name in flag position
503
+ // (ensureBranch), re-vet it — a violation is a hard failure (scene
504
+ // never created), keeping the queue drained and the repo untouched.
505
+ try {
506
+ await assertValidIntegrationBranch(git, repoRoot, integrationBranch)
507
+ } catch (error) {
508
+ return markFailedOutcome(job, 'invalid integration branch', errorText(error))
509
+ }
510
+
511
+ // Step 4 (source CAS L582-631) — queued → applying. The chain's
512
+ // single-writer property replaces the CAS (DESIGN §6.1).
513
+ store.upsertJob({ ...job, state: 'applying' })
514
+ markWorktree(job, 'merging')
515
+ flush()
516
+
517
+ // Step 5 (source L632-657) + Step 6 (baseline, source L659-671).
518
+ // CRASH-EVIDENCE WRITE ORDER (§6.1 last row): the job record carries
519
+ // `integrationWorktree` + `integrationHeadBefore` BEFORE the worktree
520
+ // exists — a crash mid-apply leaves a precise pointer to the scene.
521
+ let integrationWorktree
522
+ try {
523
+ await releaseStaleIntegrationHolds(repoRoot, job)
524
+ integrationWorktree = nextIntegrationPath(job)
525
+ const repoHead = (await git.resolveHead(repoRoot)).commit
526
+ // Bootstrap the integration branch at repo HEAD on first use.
527
+ await git.ensureBranch(repoRoot, integrationBranch, repoHead)
528
+ const integrationHeadBefore = await git.resolveRef(repoRoot, integrationBranch)
529
+ // Baseline patch. A path RECLAIMED from the abandoned list is no
530
+ // longer abandoned — drop it there so the cleanup scan never targets
531
+ // the live scene (present only when the job carries such a list).
532
+ const abandonedLeft = Array.isArray(job.abandonedIntegrationWorktrees)
533
+ ? job.abandonedIntegrationWorktrees.filter((p) => p !== integrationWorktree)
534
+ : null
535
+ store.upsertJob({
536
+ ...store.jobs[jobId],
537
+ integrationWorktree,
538
+ integrationHeadBefore,
539
+ ...(abandonedLeft !== null ? { abandonedIntegrationWorktrees: abandonedLeft } : {}),
540
+ })
541
+ flush()
542
+ // Parent pre-created; the leaf is created by git (source L633-635).
543
+ mkdirSync(path.dirname(integrationWorktree), { recursive: true })
544
+ await git.addWorktreeAt(repoRoot, integrationWorktree, integrationBranch)
545
+ } catch (error) {
546
+ // No scene to retain on a provisioning failure (source L646-657);
547
+ // removeWorktree on a never-added path is an idempotent no-op.
548
+ await git.removeWorktree(repoRoot, integrationWorktree).catch(() => {})
549
+ return markFailedOutcome(job, 'integration worktree preparation failed', errorText(error))
550
+ }
551
+
552
+ // Step 7 (source cherry-pick L673-728 → DESIGN D7 merge --no-ff).
553
+ // mergeTimeoutMs (§8.1) is THIS call's budget — separated from the
554
+ // port-level gitTimeoutMs so a slow big-repo merge is not killed at 15s.
555
+ const mergeMessage = job.message ?? defaultIntegrateMessage(job)
556
+ try {
557
+ await git.mergeNoFf(integrationWorktree, job.sourceBranch, mergeMessage, {
558
+ ...(mergeTimeoutMs !== null ? { timeoutMs: mergeTimeoutMs } : {}),
559
+ })
560
+ } catch (error) {
561
+ // Step 8 (source L688-727) — classify conflict vs hard failure.
562
+ const message = errorText(error)
563
+ let conflictFiles = []
564
+ try {
565
+ conflictFiles = [...(await git.listConflicts(integrationWorktree))]
566
+ } catch {
567
+ // listConflicts is best-effort; an empty list still counts as a
568
+ // conflict when the failure text carries a conflict marker
569
+ // (defensive semantics, source L696-704).
570
+ }
571
+ const hasConflict = conflictFiles.length > 0 || /conflict/i.test(message)
572
+ if (hasConflict) {
573
+ // RETAIN the scene: conflicted keeps the integration worktree and
574
+ // holds the branch (source markConflicted L856-898 + finally L836).
575
+ store.upsertJob({
576
+ ...store.jobs[jobId],
577
+ state: 'conflicted',
578
+ conflictFiles,
579
+ error: clamp(message),
580
+ })
581
+ markWorktree(job, 'conflicted')
582
+ flush()
583
+ return { state: 'conflicted', conflictFiles, job: store.jobs[jobId] }
584
+ }
585
+ // Hard failure: abort best-effort, then step-10 cleanup, THEN the
586
+ // failed persist (failed never retains a scene — source L826-844).
587
+ await git.abortMerge(integrationWorktree).catch(() => {})
588
+ await git.removeWorktree(repoRoot, integrationWorktree).catch(() => {})
589
+ return markFailedOutcome(job, 'merge failed', message)
590
+ }
591
+
592
+ // Step 9 (source L730-825) — success: the merge side effect is committed.
593
+ let integratedCommit
594
+ try {
595
+ integratedCommit = (await git.resolveHead(integrationWorktree)).commit
596
+ } catch (error) {
597
+ await git.removeWorktree(repoRoot, integrationWorktree).catch(() => {})
598
+ return markFailedOutcome(job, 'post-merge HEAD resolution failed', errorText(error))
599
+ }
600
+ store.upsertJob({ ...store.jobs[jobId], state: 'succeeded', integratedCommit })
601
+ markWorktree(job, 'merged', { headCommit: integratedCommit })
602
+ flush()
603
+
604
+ // Step 10 (source finally L826-844) — non-conflicted: best-effort
605
+ // removal; a removal failure must never mask the applied result.
606
+ await git.removeWorktree(repoRoot, integrationWorktree).catch(() => {})
607
+ return { state: 'succeeded', integratedCommit, job: store.jobs[jobId] }
608
+ }
609
+
610
+ // ---------------------------------------------------------------------------
611
+ // drain / applyUntilBlockedOrEmpty (DESIGN §6.2)
612
+ // ---------------------------------------------------------------------------
613
+
614
+ /**
615
+ * Drain one branch's queue to its natural stopping point (empty or a
616
+ * conflicted job holding the branch). Tail-chains onto the branch's
617
+ * promise chain — concurrent calls serialise; the returned promise is the
618
+ * CALLER's segment (await it to observe this drain's outcome).
619
+ */
620
+ function drain(repoKey, integrationBranch) {
621
+ const key = chainKey(repoKey, integrationBranch)
622
+ const base = chains.get(key) ?? Promise.resolve()
623
+ // A rejected predecessor is swallowed here so (a) it never surfaces as
624
+ // an unhandled rejection and (b) one failing segment never poisons the
625
+ // next drain — each segment still WAITS for the predecessor to settle,
626
+ // so strict serialisation is preserved.
627
+ const next = base
628
+ .catch(() => {})
629
+ .then(() => applyUntilBlockedOrEmpty(repoKey, integrationBranch))
630
+ const cleanup = () => {
631
+ if (chains.get(key) === next) chains.delete(key)
632
+ }
633
+ next.then(cleanup, cleanup)
634
+ chains.set(key, next)
635
+ return next
636
+ }
637
+
638
+ /**
639
+ * Inside the chain: apply queued jobs one at a time until the queue is
640
+ * empty (`{ drained: true }`) or an applying/conflicted job holds the
641
+ * branch (`{ blockedBy }`). A hard `failed` outcome does NOT block the
642
+ * queue (source semantics: failed is terminal, the chain continues).
643
+ */
644
+ async function applyUntilBlockedOrEmpty(repoKey, integrationBranch) {
645
+ for (;;) {
646
+ const active = store.findActiveJob(repoKey, integrationBranch)
647
+ if (active !== undefined && active.state !== 'queued') {
648
+ return { blockedBy: active }
649
+ }
650
+ const queuedJobs = store.findQueuedJobs(repoKey, integrationBranch)
651
+ if (queuedJobs.length === 0) return { drained: true }
652
+ const outcome = await applyOne(queuedJobs[0])
653
+ if (outcome.state === 'conflicted') {
654
+ return { blockedBy: store.jobs[queuedJobs[0].id] ?? queuedJobs[0] }
655
+ }
656
+ if (outcome.state === 'blocked') {
657
+ return { blockedBy: outcome.blockedBy }
658
+ }
659
+ // succeeded | failed → loop; each iteration moves one job out of
660
+ // `queued`, so the loop always makes progress.
661
+ }
662
+ }
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // Commands (source L266-400 — cancel / retry / markResolved)
666
+ // ---------------------------------------------------------------------------
667
+
668
+ /** Load a job or throw `merge_job_not_found` (source MERGE_NOT_FOUND). */
669
+ function requireJob(jobId) {
670
+ const job = store.jobs[jobId]
671
+ if (job === undefined) {
672
+ throw new MergeError('merge_job_not_found', `merge job not found: ${jobId}`)
673
+ }
674
+ return job
675
+ }
676
+
677
+ /**
678
+ * Cancel a QUEUED job → `cancelled` (source cancel L266-297).
679
+ * Idempotent on an already-cancelled job; any other state throws
680
+ * `invalid_job_state` (the message names the current state).
681
+ */
682
+ function cancel({ jobId }) {
683
+ const job = requireJob(jobId)
684
+ if (job.state === 'cancelled') return { jobId, state: 'cancelled' }
685
+ if (job.state !== 'queued') {
686
+ throw new MergeError(
687
+ 'invalid_job_state',
688
+ `cannot cancel a merge job in state '${job.state}' (only 'queued' can be cancelled)`,
689
+ )
690
+ }
691
+ store.upsertJob({ ...job, state: 'cancelled' })
692
+ flush()
693
+ return { jobId, state: 'cancelled' }
694
+ }
695
+
696
+ /**
697
+ * Retry a `failed`/`conflicted` job → `queued` (source retry L313-353).
698
+ * Clears error / integratedCommit / conflictFiles and the stale
699
+ * integration-worktree REFERENCE (the directory itself is NOT deleted —
700
+ * applyOne always provisions a fresh one; source L308-311). A retained
701
+ * directory that still exists is recorded into
702
+ * `abandonedIntegrationWorktrees` for the cleanup scan. The re-queued job
703
+ * goes to the TAIL of the branch queue (`nextOrderIndex`).
704
+ * Idempotent on an already-queued job.
705
+ */
706
+ function retry({ jobId }) {
707
+ const job = requireJob(jobId)
708
+ if (job.state === 'queued') return { jobId, state: 'queued' }
709
+ if (job.state !== 'failed' && job.state !== 'conflicted') {
710
+ throw new MergeError(
711
+ 'invalid_job_state',
712
+ `cannot retry a merge job in state '${job.state}' (only 'failed'/'conflicted' can be retried)`,
713
+ )
714
+ }
715
+ const abandoned = Array.isArray(job.abandonedIntegrationWorktrees)
716
+ ? [...job.abandonedIntegrationWorktrees]
717
+ : []
718
+ if (job.integrationWorktree && existsSync(job.integrationWorktree)) {
719
+ if (!abandoned.includes(job.integrationWorktree)) {
720
+ abandoned.push(job.integrationWorktree)
721
+ }
722
+ }
723
+ store.upsertJob({
724
+ ...job,
725
+ state: 'queued',
726
+ error: undefined, // dropped by the sanitizer — re-apply starts clean
727
+ integratedCommit: undefined,
728
+ conflictFiles: undefined,
729
+ integrationWorktree: undefined,
730
+ abandonedIntegrationWorktrees: abandoned,
731
+ orderIndex: store.nextOrderIndex(job.repoKey, job.integrationBranch),
732
+ })
733
+ flush()
734
+ releaseBranchIfIdle(job.repoKey, job.integrationBranch)
735
+ return { jobId, state: 'queued' }
736
+ }
737
+
738
+ /**
739
+ * Resolve a `conflicted` job out-of-band → `resolved` (source
740
+ * markResolved L368-400). Does NOT force-merge anything — the operator
741
+ * owns the resolution; the worktree reference fields are KEPT so the
742
+ * scene stays locatable. Idempotent on an already-resolved job.
743
+ */
744
+ function resolve({ jobId }) {
745
+ const job = requireJob(jobId)
746
+ if (job.state === 'resolved') return { jobId, state: 'resolved' }
747
+ if (job.state !== 'conflicted') {
748
+ throw new MergeError(
749
+ 'invalid_job_state',
750
+ `cannot resolve a merge job in state '${job.state}' (only 'conflicted' can be resolved)`,
751
+ )
752
+ }
753
+ store.upsertJob({ ...job, state: 'resolved' })
754
+ flush()
755
+ releaseBranchIfIdle(job.repoKey, job.integrationBranch)
756
+ return { jobId, state: 'resolved' }
757
+ }
758
+
759
+ /**
760
+ * `#releaseRetainedBranchLease` (source L967-975) in the promise-chain
761
+ * world: once a conflicted job left the active set, the branch is free —
762
+ * kick one background drain so queued jobs proceed without waiting for
763
+ * the next tool call. Fire-and-forget; failures are swallowed into the
764
+ * `onError` callback (never thrown, never unhandled).
765
+ */
766
+ function releaseBranchIfIdle(repoKey, integrationBranch) {
767
+ const key = chainKey(repoKey, integrationBranch)
768
+ if (chains.has(key)) return // a live chain re-evaluates the queue itself
769
+ drain(repoKey, integrationBranch).catch((error) => {
770
+ const text = `merge-queue: background drain on ${key} failed: ${errorText(error)}`
771
+ if (onError) {
772
+ try {
773
+ onError(text)
774
+ } catch {
775
+ /* logging must never throw */
776
+ }
777
+ }
778
+ })
779
+ }
780
+
781
+ // ---------------------------------------------------------------------------
782
+ // Projections (queue `list` data source, DESIGN §5.7)
783
+ // ---------------------------------------------------------------------------
784
+
785
+ /**
786
+ * Job records (copies), optionally filtered by repoKey / integration
787
+ * branch, enriched with `repoRoot` and `task` from the sibling maps — the
788
+ * tool layer's list projection needs both and the engine owns the maps.
789
+ */
790
+ function listJobs(filters = {}) {
791
+ const { repoKey, integrationBranch } = filters ?? {}
792
+ return Object.values(store.jobs)
793
+ .filter((job) => repoKey === undefined || job.repoKey === repoKey)
794
+ .filter((job) => integrationBranch === undefined || job.integrationBranch === integrationBranch)
795
+ .sort(
796
+ (a, b) =>
797
+ (Number(a.createdAt) || 0) - (Number(b.createdAt) || 0) ||
798
+ (Number(a.orderIndex) || 0) - (Number(b.orderIndex) || 0),
799
+ )
800
+ .map((job) => ({
801
+ ...job,
802
+ repoRoot: store.repos[job.repoKey]?.root ?? null,
803
+ task: store.worktrees[job.worktreeId]?.task ?? null,
804
+ }))
805
+ }
806
+
807
+ /** The jobs currently holding a branch slot (queued/applying/conflicted). */
808
+ function branchHolders() {
809
+ return Object.values(store.jobs)
810
+ .filter((job) => ACTIVE_JOB_STATES.has(job.state))
811
+ .map((job) => ({
812
+ repoKey: job.repoKey,
813
+ repoRoot: store.repos[job.repoKey]?.root ?? null,
814
+ integrationBranch: job.integrationBranch,
815
+ jobId: job.id,
816
+ state: job.state,
817
+ }))
818
+ }
819
+
820
+ return {
821
+ collect,
822
+ enqueue,
823
+ drain,
824
+ applyUntilBlockedOrEmpty,
825
+ applyOne,
826
+ cancel,
827
+ retry,
828
+ resolve,
829
+ listJobs,
830
+ branchHolders,
831
+ }
832
+ }