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.
- package/AGENTS.md +120 -0
- package/CHANGELOG.md +266 -0
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/README.zh.md +151 -0
- package/SECURITY.md +56 -0
- package/cordis.patch.yml +30 -0
- package/docs/DESIGN.md +783 -0
- package/docs/TASKS.md +164 -0
- package/lib/config.js +126 -0
- package/lib/engine-face.js +328 -0
- package/lib/git-port.js +773 -0
- package/lib/index.js +402 -0
- package/lib/merge-queue.js +832 -0
- package/lib/naming.js +107 -0
- package/lib/repo-gate.js +202 -0
- package/lib/state-store.js +512 -0
- package/lib/tools/worktree-cleanup.js +127 -0
- package/lib/tools/worktree-create.js +212 -0
- package/lib/tools/worktree-list.js +234 -0
- package/lib/tools/worktree-merge.js +396 -0
- package/lib/tools/worktree-queue.js +330 -0
- package/lib/tools/worktree-status.js +194 -0
- package/lib/worktree-service.js +673 -0
- package/package.json +55 -0
- package/scripts/link-harness-dsh-tools.sh +95 -0
- package/scripts/lint.js +142 -0
|
@@ -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
|
+
}
|