weir-harness 1.2.0 → 1.2.2
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/cordis.patch.yml +0 -2
- package/lib/client.blackboard-model.js +172 -56
- package/lib/client.capability-manager.js +0 -1
- package/lib/client.capability-model.js +378 -31
- package/lib/client.chain-model.js +80 -0
- package/lib/client.disabled-categories-editor.js +22 -0
- package/lib/client.edit-lock-maintenance.js +239 -246
- package/lib/client.edit-lock-panel.js +110 -0
- package/lib/client.hash-edit-model.js +48 -0
- package/lib/client.js +267 -1549
- package/lib/client.lsp-model.js +92 -0
- package/lib/client.lsp-panel.js +11 -16
- package/lib/client.lsp-toggle.js +18 -0
- package/lib/client.notify-permissions.js +112 -20
- package/lib/client.notify-web.js +14 -11
- package/lib/client.robash-model.js +26 -0
- package/lib/client.settings-page.js +287 -17
- package/lib/client.worktree-model.js +225 -0
- package/package.json +5 -2
- package/src/blackboard/durable.js +12 -0
- package/src/blackboard/index.js +2 -2
- package/src/blackboard/kernel.js +23 -6
- package/src/blackboard/notify.js +10 -3
- package/src/blackboard/remote.js +31 -13
- package/src/blackboard/storage.js +13 -5
- package/src/capabilities/apply-engine.js +189 -101
- package/src/capabilities/defaults-transaction.js +136 -1
- package/src/capabilities/lifecycle-snapshot.js +83 -204
- package/src/capabilities/mcp-admission.js +1 -23
- package/src/capabilities/mcp-drain.js +30 -19
- package/src/capabilities/mcp-facade.js +23 -3
- package/src/capabilities/preset-library.js +247 -0
- package/src/capabilities/preset-package.js +212 -1
- package/src/capabilities/same-name-policy.js +3 -41
- package/src/capabilities/selection-notify.js +324 -21
- package/src/capabilities/skill-office-adapter.js +7 -1
- package/src/capabilities/skill-selection-plugin.js +315 -729
- package/src/capabilities/store/checked-contract.d.ts +5 -0
- package/src/capabilities/store/fs-adapter.js +52 -0
- package/src/capabilities/store/lock.js +86 -0
- package/src/capabilities/store/paths.js +5 -0
- package/src/capabilities/store/store.js +105 -5
- package/src/creative-guide/guide.js +1 -1
- package/src/delegate/child-composition.js +258 -0
- package/src/delegate/group-coordinator.js +25 -27
- package/src/delegate/index.js +21 -2
- package/src/delegate/rehydrate.js +24 -4
- package/src/delegate/robash-guard-core.js +2 -2
- package/src/delegate/spawn-adapter.js +214 -100
- package/src/delegate/status-tool.js +9 -6
- package/src/delegate/supervision-mount.js +35 -6
- package/src/delegate/tool.js +29 -4
- package/src/{shared → delegate}/whitelist-defaults.js +2 -2
- package/src/edit-lock/admin-ledger.js +8 -11
- package/src/edit-lock/admin-recovery.js +2 -1
- package/src/edit-lock/admission.js +12 -2
- package/src/edit-lock/binding-guidance.js +28 -10
- package/src/edit-lock/binding-machine.js +312 -0
- package/src/edit-lock/derived-facts.js +67 -0
- package/src/edit-lock/index.js +44 -244
- package/src/edit-lock/inspect.js +113 -1
- package/src/edit-lock/lifecycle.js +8 -5
- package/src/edit-lock/limits.js +61 -0
- package/src/edit-lock/maintenance.js +5 -2
- package/src/edit-lock/manager.js +6 -2
- package/src/edit-lock/operation-history.js +1 -1
- package/src/edit-lock/peer-client.js +9 -2
- package/src/edit-lock/peer-transport.js +7 -1
- package/src/edit-lock/remote.js +11 -4
- package/src/edit-lock/state.js +2 -4
- package/src/edit-lock/view.js +10 -11
- package/src/lsp/admin.js +2 -24
- package/src/lsp/index.js +1 -1
- package/src/lsp/manager.js +1 -1
- package/src/notify/permissions-admin.js +1 -1
- package/src/{lsp/child-process.js → process/index.js} +23 -6
- package/src/settings/env-admin.js +1 -1
- package/src/settings/index.js +1 -1
- package/src/settings/sections.js +0 -54
- package/src/shared/audit.js +12 -0
- package/src/shared/child-scope.js +3 -1
- package/src/shared/http-json.js +28 -0
- package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
- package/src/worktree/errors.js +3 -0
- package/src/worktree/index.js +1 -2
- package/src/worktree/lane-core.js +641 -0
- package/src/worktree/lane-views.js +147 -0
- package/src/worktree/lane-workflows.js +853 -0
- package/src/worktree/lanes.js +24 -1513
- package/src/worktree/runner.js +5 -4
- package/src/capabilities/content-pin.js +0 -88
- package/src/capabilities/content-refresh.js +0 -331
- package/src/capabilities/distribution-manifest.js +0 -78
- package/src/capabilities/isolated-exec.js +0 -106
- package/src/capabilities/publisher.js +0 -129
- package/src/capabilities/skill-admission.js +0 -181
- package/src/capabilities/update-lifecycle.js +0 -106
- package/src/edit-lock/remote-service.js +0 -28
- package/src/worktree/contract-guidance.test.js +0 -100
package/src/worktree/lanes.js
CHANGED
|
@@ -1,1517 +1,28 @@
|
|
|
1
|
-
// Worktree lane service: the
|
|
2
|
-
//
|
|
3
|
-
// goes through this module, which
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
import { ensureExclude, hasExclude } from './exclude.js'
|
|
18
|
-
import { renderBoard, renderChildContract, renderNotice, renderWatchExpired, renderWatchHit } from './prompts.js'
|
|
19
|
-
import { cardCopy, cardLocale, hasDirtyContent } from './cards.js'
|
|
20
|
-
import { authorityResidue } from './authority.js'
|
|
21
|
-
|
|
22
|
-
export const CONFIG_FILE = '.config.json'
|
|
23
|
-
const SETUP_TIMEOUT_MS = 600_000
|
|
24
|
-
const LOG_TAIL_LINES = 40
|
|
25
|
-
|
|
26
|
-
/** Audit kind per state-machine event (AUDIT_SUBTYPES.worktree). */
|
|
27
|
-
const AUDIT_KIND = Object.freeze({
|
|
28
|
-
'setup-ok': 'setup', 'setup-fail': 'setup', 'setup-retry': 'setup', bind: 'bind', checked: 'checked',
|
|
29
|
-
'check-pass': 'check', 'check-fail': 'check', invalidate: 'invalidate', ask: 'ask', decline: 'decline',
|
|
30
|
-
conflict: 'conflict', land: 'land', keep: 'cleanup', clean: 'cleanup', abandon: 'abandon', missing: 'abandon',
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Model-facing English residue warning appended to cleanup/abandon tool
|
|
35
|
-
* results (the human-facing card copy localizes its own in cards.js).
|
|
36
|
-
* @param {{ operations: number, locks: number }} residue
|
|
37
|
-
*/
|
|
38
|
-
function residueNote(residue) {
|
|
39
|
-
const parts = []
|
|
40
|
-
if (residue.operations > 0) parts.push(`${residue.operations} unresolved Edit Lock operation(s)`)
|
|
41
|
-
if (residue.locks > 0) parts.push(`${residue.locks} Edit Lock lock(s)`)
|
|
42
|
-
return `warning: the lane's session left ${parts.join(' and ')} in the management authority; locks are reclaimed by the stale-lock sweep, unresolved publications settle via crash self-heal (dead process) or the Edit Lock maintenance panel's one-click recovery`
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Comparable form of a path: realpath when it exists, '/'-separated, and
|
|
47
|
-
* case-folded on win32.
|
|
48
|
-
* @param {string} path
|
|
49
|
-
* @param {string} [platform]
|
|
50
|
-
*/
|
|
51
|
-
export function pathKey(path, platform = process.platform) {
|
|
52
|
-
let real = path
|
|
53
|
-
try {
|
|
54
|
-
real = realpathSync.native(path)
|
|
55
|
-
} catch {
|
|
56
|
-
// a path that does not exist yet keeps its lexical form
|
|
57
|
-
let parent = dirname(path)
|
|
58
|
-
const tail = [path.slice(parent.length + 1)]
|
|
59
|
-
while (parent !== dirname(parent)) {
|
|
60
|
-
try {
|
|
61
|
-
real = join(realpathSync.native(parent), ...tail.reverse())
|
|
62
|
-
break
|
|
63
|
-
} catch {
|
|
64
|
-
tail.push(parent.slice(dirname(parent).length + 1))
|
|
65
|
-
parent = dirname(parent)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
const slashed = real.split(sep).join('/').replace(/\/+$/, '')
|
|
70
|
-
return platform === 'win32' ? slashed.toLowerCase() : slashed
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* @typedef {object} LaneServiceDeps
|
|
75
|
-
* @property {ReturnType<typeof import('./git.js').createGit>} git
|
|
76
|
-
* @property {null | ((request: { command: string, cwd: string, timeoutMs: number, session?: any }) => Promise<{ code: number, output: string, denied: boolean, timedOut: boolean }>)} shellRun
|
|
77
|
-
* @property {() => { enabled: boolean, root: string, maxActive: number, autoSetup: boolean }} settings
|
|
78
|
-
* @property {null | ((agent: any, questions: any[], signal?: AbortSignal) => Promise<{ answers: Array<{ id: string, selected: string[], custom?: string }> }>)} ask
|
|
79
|
-
* @property {(sessionId: string, text: string) => void} notify
|
|
80
|
-
* @property {(type: string, data: any, root: string, sessionId?: string | null) => void} audit
|
|
81
|
-
* @property {(session: any) => boolean} modeOf
|
|
82
|
-
* @property {(session: any) => 'manual' | 'auto-keep' | 'auto-clean'} [approveModeOf] - the session's effective auto-approve mode (projection override ?? global setting ?? module default); absent = manual (no auto paths)
|
|
83
|
-
* @property {(session: any) => 'session' | 'global'} [approveModeSourceOf] - where the effective mode comes from; absent = global
|
|
84
|
-
* @property {() => 'manual' | 'auto-keep' | 'auto-clean'} [approveGlobal] - the resolved global default (module default when unset/invalid); absent = auto-clean
|
|
85
|
-
* @property {(sessionId: string | undefined) => string | undefined} [localeOf] - the GUI language last reported for a session
|
|
86
|
-
* @property {() => number} [now]
|
|
87
|
-
* @property {(handler: () => void, delayMs: number) => any} [setTimer] - watch-expiry scheduler (tests); defaults to an unref'd setTimeout
|
|
88
|
-
* @property {(handle: any) => void} [clearTimer]
|
|
89
|
-
* @property {number} [pid] - this process id (tests)
|
|
90
|
-
* @property {{ warn?: (message: string) => void }} [logger]
|
|
91
|
-
* @property {(root: string, sessionId: string | null | undefined) => ({ operations: number, locks: number } | null)} [authorityResidue] - read-only Edit Lock residue probe feeding the cleanup/abandon warning (tests); defaults to the bounded snapshot read in ./authority.js
|
|
92
|
-
* @property {(boundChild: string, ownerSession: string | null, context: { root: string }) => Promise<{ childAlive: boolean, ownerAlive: boolean, terminalEvidence: { source: string, detail: string } | null }>} [bindingLiveness] - read-only zombie-binding probe (design D2); absent = LANE_BUSY refusals stand unreconciled
|
|
93
|
-
*/
|
|
94
|
-
|
|
95
|
-
/** @param {LaneServiceDeps} deps */
|
|
1
|
+
// Worktree lane service: the single entry point for every lane change in the
|
|
2
|
+
// product. Every lane mutation — tools, /worktree commands, child settlement —
|
|
3
|
+
// still goes through this module, which is now a thin facade assembling the
|
|
4
|
+
// three lane modules: lane-core.js owns the ledger + check engine (the one
|
|
5
|
+
// apply() every state change goes through), lane-workflows.js the decision
|
|
6
|
+
// workflows (cards, auto-approve, removal semantics), lane-views.js the
|
|
7
|
+
// read-only views. ctx-free by construction: git, shell, questions,
|
|
8
|
+
// notifications, audit and the session-mode reader are injected
|
|
9
|
+
// (src/worktree/index.js wires them).
|
|
10
|
+
import { createLaneCore } from './lane-core.js'
|
|
11
|
+
import { createLaneWorkflows } from './lane-workflows.js'
|
|
12
|
+
import { createLaneViews } from './lane-views.js'
|
|
13
|
+
|
|
14
|
+
export { CONFIG_FILE, pathKey } from './lane-core.js'
|
|
15
|
+
|
|
16
|
+
/** @param {import('./lane-core.js').LaneServiceDeps} deps */
|
|
96
17
|
export function createLaneService(deps) {
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
/** Where the effective mode comes from ('session' override vs 'global' setting). @param {any} session */
|
|
108
|
-
const approveModeSourceSafe = (session) => {
|
|
109
|
-
try {
|
|
110
|
-
return deps.approveModeSourceOf?.(session) ?? 'global'
|
|
111
|
-
} catch {
|
|
112
|
-
return 'global'
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
/** @type {Map<string, Promise<any>>} cwd+root → repo */
|
|
116
|
-
const pending = new Map()
|
|
117
|
-
/** @type {Map<string, any>} cwd+root → resolved repo (sync access for the prompt board) */
|
|
118
|
-
const resolved = new Map()
|
|
119
|
-
/** @type {Promise<number[] | null> | null} */
|
|
120
|
-
let versionProbe = null
|
|
121
|
-
/** Lanes whose approval card is open in THIS process. @type {Set<string>} */
|
|
122
|
-
const asking = new Set()
|
|
123
|
-
const pid = deps.pid ?? process.pid
|
|
124
|
-
/** Whether an approval card recorded in the ledger is still open somewhere. @param {any} lane */
|
|
125
|
-
const askAlive = (lane) => {
|
|
126
|
-
const owner = lane.asking?.pid
|
|
127
|
-
if (owner === pid) return asking.has(lane.id)
|
|
128
|
-
if (typeof owner !== 'number') return false
|
|
129
|
-
try {
|
|
130
|
-
process.kill(owner, 0)
|
|
131
|
-
return true
|
|
132
|
-
} catch {
|
|
133
|
-
return false
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// ─── watch-expiry timers (this instance only; the ledger owns the truth) ─
|
|
138
|
-
|
|
139
|
-
/** Watch-expiry timers armed by THIS service instance, watch id → handle.
|
|
140
|
-
* The lock arbitrates between instances: whichever instance removes the
|
|
141
|
-
* record under the ledger lock delivers that watch's single expiry notice. */
|
|
142
|
-
const watchTimers = new Map()
|
|
143
|
-
const setTimer = deps.setTimer ?? ((/** @type {() => void} */ handler, /** @type {number} */ delayMs) => {
|
|
144
|
-
const timer = setTimeout(handler, delayMs)
|
|
145
|
-
timer.unref?.()
|
|
146
|
-
return timer
|
|
147
|
-
})
|
|
148
|
-
const clearTimer = deps.clearTimer ?? clearTimeout
|
|
149
|
-
|
|
150
|
-
/** @param {string} watchId */
|
|
151
|
-
function disarmWatch(watchId) {
|
|
152
|
-
const handle = watchTimers.get(watchId)
|
|
153
|
-
if (handle !== undefined) {
|
|
154
|
-
clearTimer(handle)
|
|
155
|
-
watchTimers.delete(watchId)
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** @param {any} repo @param {any} entry */
|
|
160
|
-
function armWatch(repo, entry) {
|
|
161
|
-
if (watchTimers.has(entry.id)) return
|
|
162
|
-
const delay = entry.expiresAt - now()
|
|
163
|
-
if (delay <= 0) return
|
|
164
|
-
watchTimers.set(entry.id, setTimer(() => {
|
|
165
|
-
watchTimers.delete(entry.id)
|
|
166
|
-
void expireWatch(repo, entry.id).catch((error) => deps.logger?.warn?.(`worktree: watch expiry failed: ${/** @type {any} */ (error)?.message ?? error}`))
|
|
167
|
-
}, delay))
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* An expiry timer fired: remove the watch under the ledger lock; only the
|
|
172
|
-
* instance that actually removes the record delivers the expiry notice
|
|
173
|
-
* (the watch's single delivery), so two instances can never double-deliver.
|
|
174
|
-
* @param {any} repo @param {string} watchId
|
|
175
|
-
*/
|
|
176
|
-
async function expireWatch(repo, watchId) {
|
|
177
|
-
const removed = await repo.ledger.update((/** @type {any} */ ledger) => {
|
|
178
|
-
const watch = removeWatchForExpiry(ledger, watchId, now())
|
|
179
|
-
return watch ? { ledger, result: watch } : {}
|
|
180
|
-
})
|
|
181
|
-
if (!removed) return
|
|
182
|
-
deps.audit('watch', { lane: removed.laneId, sessionId: removed.sessionId, states: removed.states, outcome: 'expired' }, repo.mainRoot, removed.sessionId)
|
|
183
|
-
try {
|
|
184
|
-
deps.notify(removed.sessionId, renderWatchExpired(removed))
|
|
185
|
-
} catch (error) {
|
|
186
|
-
deps.logger?.warn?.(`worktree: watch-expiry notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Ledger lazy-cleanup + per-instance timer arming (design D4). Runs on every
|
|
192
|
-
* refresh — which is also the host-load path: watches whose deadline passed
|
|
193
|
-
* while the host was down are pruned silently + audited (never delivered,
|
|
194
|
-
* never hit-tested); every live watch gets this instance's expiry timer.
|
|
195
|
-
* @param {any} repo
|
|
196
|
-
*/
|
|
197
|
-
async function syncWatches(repo) {
|
|
198
|
-
const pruned = await repo.ledger.update((/** @type {any} */ ledger) => {
|
|
199
|
-
const expired = pruneExpiredWatches(ledger, now())
|
|
200
|
-
return expired.length > 0 ? { ledger, result: expired } : {}
|
|
201
|
-
})
|
|
202
|
-
for (const watch of pruned ?? []) {
|
|
203
|
-
deps.audit('watch', { lane: watch.laneId, sessionId: watch.sessionId, states: watch.states, outcome: 'pruned' }, repo.mainRoot, watch.sessionId)
|
|
204
|
-
}
|
|
205
|
-
for (const entry of repo.ledger.read().watches) armWatch(repo, entry)
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const cwdOf = (/** @type {any} */ session) => {
|
|
209
|
-
const cwd = session?.header?.cwd
|
|
210
|
-
if (typeof cwd !== 'string' || !cwd) throw new WorktreeError(WORKTREE_CODES.NOT_A_REPO, 'this session has no working directory')
|
|
211
|
-
return cwd
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function config() {
|
|
215
|
-
const value = settings()
|
|
216
|
-
if (!value.enabled) throw new WorktreeError(WORKTREE_CODES.WORKTREE_DISABLED, 'worktree lanes are disabled (worktreeEnabled is off)')
|
|
217
|
-
return value
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** @param {string} cwd */
|
|
221
|
-
async function repoFor(cwd) {
|
|
222
|
-
const root = normalizeRoot(config().root)
|
|
223
|
-
const key = `${cwd}\0${root}`
|
|
224
|
-
if (!pending.has(key)) {
|
|
225
|
-
const promise = resolveRepo(cwd, root).then((repo) => {
|
|
226
|
-
resolved.set(key, repo)
|
|
227
|
-
return repo
|
|
228
|
-
})
|
|
229
|
-
promise.catch(() => pending.delete(key))
|
|
230
|
-
pending.set(key, promise)
|
|
231
|
-
}
|
|
232
|
-
return pending.get(key)
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** @param {string} cwd @param {string} root */
|
|
236
|
-
async function resolveRepo(cwd, root) {
|
|
237
|
-
versionProbe ??= git.version(cwd)
|
|
238
|
-
const version = await versionProbe
|
|
239
|
-
if (!git.supported(version)) {
|
|
240
|
-
versionProbe = null
|
|
241
|
-
throw new WorktreeError(WORKTREE_CODES.GIT_TOO_OLD, version
|
|
242
|
-
? `git ${version.join('.')} is too old; worktree lanes need git 2.38 or newer`
|
|
243
|
-
: `git could not be run (${git.lastVersionError ?? 'unknown error'}); worktree lanes need git 2.38 or newer`)
|
|
244
|
-
}
|
|
245
|
-
const info = await git.repoOf(cwd)
|
|
246
|
-
if (!info) throw new WorktreeError(WORKTREE_CODES.NOT_A_REPO, `${cwd} is not inside a git repository`)
|
|
247
|
-
const mainRoot = realpathSync.native(info.mainRoot)
|
|
248
|
-
const rootPath = join(mainRoot, ...root.split('/'))
|
|
249
|
-
return { cwd, mainRoot, commonDir: info.commonDir, root, rootPath, ledger: createLedger(rootPath, { now }), version, unmanaged: /** @type {string[]} */ ([]) }
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** Resolved repo for sync callers (prompt board), or null. @param {any} session */
|
|
253
|
-
function repoCached(session) {
|
|
254
|
-
try {
|
|
255
|
-
const value = settings()
|
|
256
|
-
if (!value.enabled) return null
|
|
257
|
-
const cwd = cwdOf(session)
|
|
258
|
-
const key = `${cwd}\0${normalizeRoot(value.root)}`
|
|
259
|
-
if (!resolved.has(key) && !pending.has(key)) void repoFor(cwd).catch(() => {})
|
|
260
|
-
return resolved.get(key) ?? null
|
|
261
|
-
} catch {
|
|
262
|
-
return null
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// ─── ledger helpers ───────────────────────────────────────────────────
|
|
267
|
-
|
|
268
|
-
/** @param {any} repo @param {string} laneId */
|
|
269
|
-
function laneOf(repo, laneId) {
|
|
270
|
-
const lane = repo.ledger.read().lanes.find((/** @type {any} */ entry) => entry.id === laneId)
|
|
271
|
-
if (!lane) throw new WorktreeError(WORKTREE_CODES.UNKNOWN_LANE, `no lane "${laneId}" in ${repo.rootPath}`)
|
|
272
|
-
return lane
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Transition one lane under the ledger lock; `check` runs inside the lock
|
|
277
|
-
* against the current record (throw to refuse).
|
|
278
|
-
* @param {any} repo @param {string} laneId @param {any} event @param {(lane: any, ledger: any) => void} [check]
|
|
279
|
-
*/
|
|
280
|
-
async function apply(repo, laneId, event, check) {
|
|
281
|
-
/** Watches this transition consumed (removed in the same atomic write). @type {any[]} */
|
|
282
|
-
let hits = []
|
|
283
|
-
const lane = await repo.ledger.update((/** @type {any} */ ledger) => {
|
|
284
|
-
const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
|
|
285
|
-
if (index === -1) throw new WorktreeError(WORKTREE_CODES.UNKNOWN_LANE, `no lane "${laneId}"`)
|
|
286
|
-
check?.(ledger.lanes[index], ledger)
|
|
287
|
-
const next = transition(ledger.lanes[index], { at: now(), ...event })
|
|
288
|
-
ledger.lanes[index] = next
|
|
289
|
-
// One-shot watches: a hit is removed in the SAME atomic write as the
|
|
290
|
-
// state transition, so it can never be collected twice (design D3).
|
|
291
|
-
hits = collectWatchHits(ledger, next)
|
|
292
|
-
return { ledger, result: next }
|
|
293
|
-
})
|
|
294
|
-
const kind = AUDIT_KIND[/** @type {keyof typeof AUDIT_KIND} */ (event.type)]
|
|
295
|
-
// `event.audit` carries extra payload for the SAME kind (the auto-approve
|
|
296
|
-
// marker on land/abandon) without a new audit type; transition() ignores
|
|
297
|
-
// the key, so it never leaks into the lane record.
|
|
298
|
-
if (kind) deps.audit(kind, { lane: lane.id, from: lane.history.at(-1)?.from, to: lane.state, event: event.type, reason: event.reason ?? null, by: event.by ?? 'host', ...(event.audit ?? {}) }, repo.mainRoot, lane.ownerSession)
|
|
299
|
-
for (const hit of hits) {
|
|
300
|
-
disarmWatch(hit.id)
|
|
301
|
-
try {
|
|
302
|
-
deps.notify(hit.sessionId, renderWatchHit(lane))
|
|
303
|
-
} catch (error) {
|
|
304
|
-
deps.logger?.warn?.(`worktree: watch-hit notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
return lane
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/** Non-state field update (no transition). @param {any} repo @param {string} laneId @param {(lane: any) => any} patch */
|
|
311
|
-
async function patchLane(repo, laneId, patch) {
|
|
312
|
-
return repo.ledger.update((/** @type {any} */ ledger) => {
|
|
313
|
-
const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
|
|
314
|
-
if (index === -1) return {}
|
|
315
|
-
ledger.lanes[index] = { ...ledger.lanes[index], ...patch(ledger.lanes[index]), updatedAt: now() }
|
|
316
|
-
return { ledger, result: ledger.lanes[index] }
|
|
317
|
-
})
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** @param {any} lane @param {string} [detail] */
|
|
321
|
-
function notifyOwner(lane, detail) {
|
|
322
|
-
if (!lane?.ownerSession) return
|
|
323
|
-
try {
|
|
324
|
-
deps.notify(lane.ownerSession, renderNotice(lane, detail))
|
|
325
|
-
} catch (error) {
|
|
326
|
-
deps.logger?.warn?.(`worktree: notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Best-effort Edit Lock authority-residue probe (design D5): does the
|
|
332
|
-
* lane's owner session still own unresolved operations or locks in the
|
|
333
|
-
* management domain? Warning input only — read-only, and a failed check
|
|
334
|
-
* is silent (never blocks the cleanup/abandon transition).
|
|
335
|
-
* @param {any} repo @param {any} lane @returns {{ operations: number, locks: number } | null}
|
|
336
|
-
*/
|
|
337
|
-
function residueOf(repo, lane) {
|
|
338
|
-
try {
|
|
339
|
-
return (deps.authorityResidue ?? authorityResidue)(repo.mainRoot, lane?.ownerSession) ?? null
|
|
340
|
-
} catch (error) {
|
|
341
|
-
deps.logger?.warn?.(`worktree: authority-residue check failed: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
342
|
-
return null
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** @param {any} lane */
|
|
347
|
-
function result(lane, summary, extra = {}) {
|
|
348
|
-
return {
|
|
349
|
-
lane: lane.id, state: lane.state, summary, path: lane.path, branch: lane.branch, next: nextFor(lane),
|
|
350
|
-
...(lane.check?.enabled && Array.isArray(lane.check.results) && lane.check.results.length ? { check: { results: lane.check.results.map((/** @type {any} */ entry) => ({ name: entry.name, exit: entry.exit, ms: entry.ms })) } } : {}),
|
|
351
|
-
...extra,
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/** @param {any} repo */
|
|
356
|
-
function readConfig(repo) {
|
|
357
|
-
const file = join(repo.rootPath, CONFIG_FILE)
|
|
358
|
-
if (!existsSync(file)) return parseRepoConfig(undefined)
|
|
359
|
-
let raw
|
|
360
|
-
try {
|
|
361
|
-
raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
362
|
-
} catch (error) {
|
|
363
|
-
throw new Error(`invalid ${file}: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
364
|
-
}
|
|
365
|
-
try {
|
|
366
|
-
return parseRepoConfig(raw)
|
|
367
|
-
} catch (error) {
|
|
368
|
-
throw new Error(`invalid ${file}: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/** @param {any} repo @param {string} laneId @param {string} name */
|
|
373
|
-
function logFile(repo, laneId, name) {
|
|
374
|
-
const dir = join(repo.rootPath, '.logs', laneId)
|
|
375
|
-
mkdirSync(dir, { recursive: true })
|
|
376
|
-
return join(dir, `${name}.log`)
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// ─── reconciliation ───────────────────────────────────────────────────
|
|
380
|
-
|
|
381
|
-
/** @param {any} repo */
|
|
382
|
-
async function refresh(repo) {
|
|
383
|
-
// Watch maintenance first (design D4): prune watches that expired while
|
|
384
|
-
// the host was down (silent + audited) and arm this instance's timers;
|
|
385
|
-
// the reconcile transitions below then hit only live watches.
|
|
386
|
-
await syncWatches(repo)
|
|
387
|
-
const worktrees = await git.worktreeList(repo.mainRoot)
|
|
388
|
-
const ledger = repo.ledger.read()
|
|
389
|
-
const mainBranch = await git.currentBranch(repo.mainRoot)
|
|
390
|
-
/** @type {Map<string, string | null>} */
|
|
391
|
-
const trees = new Map()
|
|
392
|
-
for (const lane of ledger.lanes) {
|
|
393
|
-
if (LANDABLE_FROM.includes(lane.state) && lane.landableTree && existsSync(lane.path)) trees.set(lane.id, await git.tree(lane.path))
|
|
394
|
-
}
|
|
395
|
-
const outcome = reconcile({ lanes: ledger.lanes, worktrees, rootPath: repo.rootPath, mainBranch, trees, normalize: (path) => pathKey(path) })
|
|
396
|
-
for (const event of outcome.events) {
|
|
397
|
-
await apply(repo, event.id, { type: event.type, reason: event.reason }).catch(() => {})
|
|
398
|
-
}
|
|
399
|
-
// An approval card dies with the tool call that raised it (restart,
|
|
400
|
-
// crash): such a lane is declined, never left waiting forever.
|
|
401
|
-
for (const lane of ledger.lanes) {
|
|
402
|
-
if (lane.state === 'awaiting-approval' && !askAlive(lane)) {
|
|
403
|
-
await apply(repo, lane.id, { type: 'decline', reason: 'the approval card was closed before an answer (the tool call ended)' }).catch(() => {})
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
const flagsChanged = ledger.lanes.some((/** @type {any} */ lane) => outcome.baseMoved.has(lane.id) && Boolean(lane.baseMoved) !== outcome.baseMoved.get(lane.id))
|
|
407
|
-
if (flagsChanged) {
|
|
408
|
-
await repo.ledger.update((/** @type {any} */ current) => {
|
|
409
|
-
for (const lane of current.lanes) if (outcome.baseMoved.has(lane.id)) lane.baseMoved = outcome.baseMoved.get(lane.id)
|
|
410
|
-
return { ledger: current }
|
|
411
|
-
})
|
|
412
|
-
}
|
|
413
|
-
if (outcome.unmanaged.length && outcome.unmanaged.join() !== repo.unmanaged.join()) {
|
|
414
|
-
deps.audit('reconcile', { unmanaged: outcome.unmanaged }, repo.mainRoot, null)
|
|
415
|
-
}
|
|
416
|
-
repo.unmanaged = outcome.unmanaged
|
|
417
|
-
return { mainBranch, unmanaged: outcome.unmanaged }
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// ─── open / setup ─────────────────────────────────────────────────────
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* The invocation for a DERIVED setup: resolved through deps.resolveSetup
|
|
424
|
-
* when available (system tool first, DSH bundled runtime second), else the
|
|
425
|
-
* legacy bare command. A user-configured setup never reaches this helper.
|
|
426
|
-
* @param {string} manager
|
|
427
|
-
* @returns {Promise<{ command: string, display: string } | { error: string }>}
|
|
428
|
-
*/
|
|
429
|
-
async function deriveSetup(manager) {
|
|
430
|
-
if (deps.resolveSetup) {
|
|
431
|
-
const resolution = await deps.resolveSetup(manager)
|
|
432
|
-
if (!resolution.ok) return { error: setupMissingReason(manager) }
|
|
433
|
-
return { command: resolution.command, display: resolution.display }
|
|
434
|
-
}
|
|
435
|
-
const display = bareSetupCommand(manager)
|
|
436
|
-
return { command: display, display }
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* @param {any} session - the owning (main) session
|
|
442
|
-
* @param {{ title: string, scope?: string[] }} args
|
|
443
|
-
*/
|
|
444
|
-
async function open(session, args) {
|
|
445
|
-
const settingsNow = config()
|
|
446
|
-
if (typeof args?.title !== 'string' || args.title.trim().length === 0) throw new Error('worktree_open: title must be a non-empty string')
|
|
447
|
-
const scope = args.scope ?? []
|
|
448
|
-
if (!Array.isArray(scope) || scope.some((glob) => typeof glob !== 'string' || glob.trim().length === 0)) throw new Error('worktree_open: scope must be an array of non-empty path globs')
|
|
449
|
-
const repo = await repoFor(cwdOf(session))
|
|
450
|
-
const base = await git.currentBranch(repo.mainRoot)
|
|
451
|
-
if (!base) throw new WorktreeError(WORKTREE_CODES.DETACHED_HEAD, 'the main worktree is on a detached HEAD; check out a branch first', { next: { waitFor: 'user', hint: 'the user checks out a base branch' } })
|
|
452
|
-
const baseCommit = await git.revParse(repo.mainRoot, 'HEAD')
|
|
453
|
-
await refresh(repo)
|
|
454
|
-
ensureExclude(repo.commonDir, repo.root)
|
|
455
|
-
const at = now()
|
|
456
|
-
const lane = await repo.ledger.update((/** @type {any} */ ledger) => {
|
|
457
|
-
const active = ledger.lanes.filter(isActive)
|
|
458
|
-
if (active.length >= settingsNow.maxActive) {
|
|
459
|
-
const lanes = active.map((/** @type {any} */ lane) => ({
|
|
460
|
-
id: lane.id, state: lane.state,
|
|
461
|
-
hint: lane.state === 'landable'
|
|
462
|
-
? `call worktree_land({ lane: "${lane.id}" })`
|
|
463
|
-
: `wait for lane ${lane.id} or call worktree_abandon({ lane: "${lane.id}" })`,
|
|
464
|
-
}))
|
|
465
|
-
const hint = (lanes.find((lane) => lane.state === 'landable') ?? lanes[0]).hint
|
|
466
|
-
throw new WorktreeError(WORKTREE_CODES.MAX_ACTIVE, `${active.length} lanes are active (limit ${settingsNow.maxActive}):\n${lanes.map((lane) => `${lane.id} · ${lane.state} · ${lane.hint}`).join('\n')}`, {
|
|
467
|
-
data: { lanes }, next: { waitFor: 'user', hint },
|
|
468
|
-
})
|
|
469
|
-
}
|
|
470
|
-
const clash = active.find((/** @type {any} */ other) => scopesOverlap(scope, other.scope ?? []))
|
|
471
|
-
if (clash) {
|
|
472
|
-
const overlapping = {
|
|
473
|
-
scope: scope.filter((glob) => scopesOverlap([glob], clash.scope)),
|
|
474
|
-
laneScope: clash.scope.filter((glob) => scopesOverlap(scope, [glob])),
|
|
475
|
-
}
|
|
476
|
-
const hint = `narrow the new scope to avoid ${overlapping.laneScope.join(', ')} or wait for lane ${clash.id} to land before opening it`
|
|
477
|
-
throw new WorktreeError(WORKTREE_CODES.SCOPE_OVERLAP, `scope ${overlapping.scope.join(', ')} overlaps lane ${clash.id} (${overlapping.laneScope.join(', ')}); ${hint}`, {
|
|
478
|
-
lane: clash.id, data: { overlapping }, next: { waitFor: 'user', hint },
|
|
479
|
-
})
|
|
480
|
-
}
|
|
481
|
-
const seq = ledger.seq + 1
|
|
482
|
-
const id = laneIdFor(args.title, seq)
|
|
483
|
-
const record = {
|
|
484
|
-
id,
|
|
485
|
-
title: args.title.trim(),
|
|
486
|
-
path: join(repo.rootPath, id),
|
|
487
|
-
branch: branchFor(id),
|
|
488
|
-
base: { branch: base, commit: baseCommit },
|
|
489
|
-
scope,
|
|
490
|
-
state: 'preparing',
|
|
491
|
-
ownerSession: session.id ?? null,
|
|
492
|
-
boundChild: null,
|
|
493
|
-
baseMoved: false,
|
|
494
|
-
landableTree: null,
|
|
495
|
-
setup: { status: 'pending' },
|
|
496
|
-
check: null,
|
|
497
|
-
land: null,
|
|
498
|
-
cleanup: null,
|
|
499
|
-
reason: null,
|
|
500
|
-
createdAt: at,
|
|
501
|
-
updatedAt: at,
|
|
502
|
-
history: [],
|
|
503
|
-
}
|
|
504
|
-
return { ledger: { ...ledger, seq, lanes: [...ledger.lanes, record] }, result: record }
|
|
505
|
-
})
|
|
506
|
-
try {
|
|
507
|
-
if (await git.branchExists(repo.mainRoot, lane.branch)) throw new WorktreeError(WORKTREE_CODES.BRANCH_EXISTS, `branch ${lane.branch} already exists`)
|
|
508
|
-
mkdirSync(repo.rootPath, { recursive: true })
|
|
509
|
-
await git.worktreeAdd(repo.mainRoot, lane.path, lane.branch, baseCommit)
|
|
510
|
-
} catch (error) {
|
|
511
|
-
await repo.ledger.update((/** @type {any} */ ledger) => ({ ledger: { ...ledger, lanes: ledger.lanes.filter((/** @type {any} */ entry) => entry.id !== lane.id) } }))
|
|
512
|
-
throw error
|
|
513
|
-
}
|
|
514
|
-
deps.audit('open', { lane: lane.id, title: lane.title, branch: lane.branch, base: lane.base, scope }, repo.mainRoot, lane.ownerSession)
|
|
515
|
-
let command = null
|
|
516
|
-
let display = null
|
|
517
|
-
/** @type {{ kind: 'configured' } | { kind: 'derived', manager: string } | null} */
|
|
518
|
-
let provenance = null
|
|
519
|
-
let configError = null
|
|
520
|
-
if (settingsNow.autoSetup) {
|
|
521
|
-
try {
|
|
522
|
-
const configured = readConfig(repo).setup ?? null
|
|
523
|
-
if (configured) {
|
|
524
|
-
command = display = configured
|
|
525
|
-
provenance = { kind: 'configured' }
|
|
526
|
-
} else {
|
|
527
|
-
const manager = setupManagerFor(readdirSync(lane.path))
|
|
528
|
-
if (manager) {
|
|
529
|
-
provenance = { kind: 'derived', manager }
|
|
530
|
-
const derived = await deriveSetup(manager)
|
|
531
|
-
if ('error' in derived) configError = derived.error.replaceAll('<lane>', lane.id)
|
|
532
|
-
else ({ command, display } = derived)
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
} catch (error) {
|
|
536
|
-
configError = /** @type {any} */ (error)?.message ?? String(error)
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
if (configError) {
|
|
540
|
-
const failed = await apply(repo, lane.id, { type: 'setup-fail', reason: configError, patch: { setup: { status: 'failed' } } })
|
|
541
|
-
return result(failed, `lane opened at ${failed.path}, but setup could not start: ${configError}`)
|
|
542
|
-
}
|
|
543
|
-
if (!command) {
|
|
544
|
-
const ready = await apply(repo, lane.id, { type: 'setup-ok', patch: { setup: { status: settingsNow.autoSetup ? 'none' : 'disabled' } } })
|
|
545
|
-
return result(ready, `lane opened at ${ready.path} on ${ready.branch} (base ${base})`)
|
|
546
|
-
}
|
|
547
|
-
const preparing = await patchLane(repo, lane.id, () => ({ setup: { status: 'running', display: display ?? command, provenance } }))
|
|
548
|
-
void runSetup(repo, lane.id, command, session, display ?? command)
|
|
549
|
-
return result(preparing, `lane opened at ${preparing.path}; running setup: ${display ?? command}`)
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/** @param {any} repo @param {string} laneId @param {string} command @param {any} session @param {string} [display] */
|
|
553
|
-
async function runSetup(repo, laneId, command, session, display = command) {
|
|
554
|
-
const log = logFile(repo, laneId, '0-setup')
|
|
555
|
-
/** @type {{ ok: boolean, reason?: string }} */
|
|
556
|
-
let outcome
|
|
557
|
-
if (!deps.shellRun) {
|
|
558
|
-
outcome = { ok: false, reason: `${WORKTREE_CODES.SHELL_UNAVAILABLE}: no shell executor is available to run "${command}"` }
|
|
559
|
-
} else {
|
|
560
|
-
try {
|
|
561
|
-
const run = await deps.shellRun({ command, cwd: laneOf(repo, laneId).path, timeoutMs: SETUP_TIMEOUT_MS, session })
|
|
562
|
-
writeFileSync(log, `$ ${display}\n\n${run.output}`)
|
|
563
|
-
outcome = run.code === 0
|
|
564
|
-
? { ok: true }
|
|
565
|
-
: { ok: false, reason: run.denied ? `sandbox denied the setup (exit ${run.code}); switch the session permission and retry with /worktree setup ${laneId}, or skip with /worktree setup ${laneId} --skip` : run.timedOut ? 'setup timed out' : `setup exited ${run.code}` }
|
|
566
|
-
} catch (error) {
|
|
567
|
-
outcome = { ok: false, reason: `setup could not run: ${/** @type {any} */ (error)?.message ?? error}` }
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
try {
|
|
571
|
-
const lane = await apply(repo, laneId, outcome.ok
|
|
572
|
-
? { type: 'setup-ok', patch: { setup: { ...laneOf(repo, laneId).setup, status: 'ok', display, log } } }
|
|
573
|
-
: { type: 'setup-fail', reason: outcome.reason, patch: { setup: { ...laneOf(repo, laneId).setup, status: 'failed', display, log } } })
|
|
574
|
-
notifyOwner(lane, outcome.ok ? `setup finished: ${display}` : `${outcome.reason}; log ${log}`)
|
|
575
|
-
} catch {
|
|
576
|
-
// the lane moved on (abandoned) while setup ran: nothing to report
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/** /worktree setup <id> [--skip] @param {any} session @param {string} laneId @param {{ skip?: boolean }} options */
|
|
581
|
-
async function setup(session, laneId, options = {}) {
|
|
582
|
-
const repo = await repoFor(cwdOf(session))
|
|
583
|
-
if (options.skip) {
|
|
584
|
-
const lane = await apply(repo, laneId, { type: 'setup-ok', by: 'user', patch: { setup: { ...laneOf(repo, laneId).setup, status: 'skipped' } } })
|
|
585
|
-
return result(lane, 'setup skipped by the user')
|
|
586
|
-
}
|
|
587
|
-
const current = laneOf(repo, laneId)
|
|
588
|
-
// Execution strings are NEVER taken from the ledger: a stored display
|
|
589
|
-
// may be the bare or unquoted form, and tools change between attempts.
|
|
590
|
-
// Provenance decides the source — config wins, otherwise the lane's
|
|
591
|
-
// lockfile (or the manager recorded at open) — and derived commands are
|
|
592
|
-
// resolved fresh on every attempt.
|
|
593
|
-
let command = null
|
|
594
|
-
let display = current.setup?.display ?? null
|
|
595
|
-
/** @type {string | null} */
|
|
596
|
-
let manager = null
|
|
597
|
-
try {
|
|
598
|
-
const configured = readConfig(repo).setup ?? null
|
|
599
|
-
if (configured) {
|
|
600
|
-
command = display = configured
|
|
601
|
-
} else {
|
|
602
|
-
manager = setupManagerFor(readdirSync(current.path)) ?? current.setup?.provenance?.manager ?? null
|
|
603
|
-
if (manager) {
|
|
604
|
-
const derived = await deriveSetup(manager)
|
|
605
|
-
if ('error' in derived) {
|
|
606
|
-
// Still setup-failed: keep the state legal and refresh the reason.
|
|
607
|
-
const failed = await patchLane(repo, laneId, (record) => ({
|
|
608
|
-
setup: { ...record.setup, status: 'failed' },
|
|
609
|
-
reason: derived.error.replaceAll('<lane>', laneId),
|
|
610
|
-
}))
|
|
611
|
-
return result(failed, `setup could not start: ${failed.reason}`)
|
|
612
|
-
}
|
|
613
|
-
({ command, display } = derived)
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
} catch (error) {
|
|
617
|
-
throw new Error(/** @type {any} */ (error)?.message ?? String(error))
|
|
618
|
-
}
|
|
619
|
-
if (!command) {
|
|
620
|
-
const lane = await apply(repo, laneId, { type: 'setup-ok', by: 'user', patch: { setup: { status: 'none' } } })
|
|
621
|
-
return result(lane, 'no setup command applies; lane is ready')
|
|
622
|
-
}
|
|
623
|
-
const provenance = manager ? { kind: 'derived', manager } : { kind: 'configured' }
|
|
624
|
-
const lane = await apply(repo, laneId, { type: 'setup-retry', by: 'user', patch: { setup: { status: 'running', display: display ?? command, provenance } } })
|
|
625
|
-
void runSetup(repo, laneId, command, session, display ?? command)
|
|
626
|
-
return result(lane, `setup restarted: ${display ?? command}`)
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// ─── binding ──────────────────────────────────────────────────────────
|
|
630
|
-
|
|
631
|
-
/** One-phrase reconciliation outcome for LANE_BUSY messages. @param {any} verdict */
|
|
632
|
-
function reconcileNote(verdict) {
|
|
633
|
-
if (verdict?.childAlive) return 'the bound worker is still live'
|
|
634
|
-
if (verdict?.ownerAlive) return 'the lane owner session is still live'
|
|
635
|
-
return 'no terminal evidence exists for the bound worker (offline is not dead)'
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
/**
|
|
639
|
-
* Zombie-binding reconciliation (worktree-zombie-lane-reclamation D1/D2):
|
|
640
|
-
* the read-only liveness probe runs before any LANE_BUSY refusal. Only
|
|
641
|
-
* terminal evidence ∧ both sides dead settles the binding — through the
|
|
642
|
-
* exact childSettled path, so a reconciled settlement is indistinguishable
|
|
643
|
-
* from a live one downstream — and the settlement is audited
|
|
644
|
-
* (worktree/reconcile-binding), exactly once (the call that actually
|
|
645
|
-
* cleared the binding audits; a concurrent/repeat reconcile finds the
|
|
646
|
-
* binding gone and audits nothing). A refusal leaves the ledger untouched.
|
|
647
|
-
* `forced` skips the probe: the user's explicit card decision (D4).
|
|
648
|
-
* @param {any} repo @param {any} lane @param {any} session @param {{ forced?: boolean }} [options]
|
|
649
|
-
* @returns {Promise<{ settled: boolean, lane: any, verdict: any, childId: string, forced: boolean } | null>} null = no binding, or no probe available
|
|
650
|
-
*/
|
|
651
|
-
async function reconcileBinding(repo, lane, session, options = {}) {
|
|
652
|
-
if (!lane?.boundChild) return null
|
|
653
|
-
const childId = lane.boundChild
|
|
654
|
-
const forced = options.forced === true
|
|
655
|
-
/** @type {any} */
|
|
656
|
-
let verdict = null
|
|
657
|
-
if (!forced) {
|
|
658
|
-
if (typeof deps.bindingLiveness !== 'function') return null
|
|
659
|
-
try {
|
|
660
|
-
verdict = await deps.bindingLiveness(childId, lane.ownerSession ?? null, { root: repo.mainRoot })
|
|
661
|
-
} catch (error) {
|
|
662
|
-
deps.logger?.warn?.(`worktree: binding liveness probe failed for lane ${lane.id}: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
663
|
-
return null
|
|
664
|
-
}
|
|
665
|
-
if (!verdict || verdict.childAlive || verdict.ownerAlive || !verdict.terminalEvidence) {
|
|
666
|
-
return { settled: false, lane, verdict, childId, forced }
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
const cleared = await childSettled(childId, session)
|
|
670
|
-
const after = laneOf(repo, lane.id)
|
|
671
|
-
if (cleared != null) {
|
|
672
|
-
deps.audit('reconcile-binding', {
|
|
673
|
-
lane: lane.id,
|
|
674
|
-
child: childId,
|
|
675
|
-
ownerSession: lane.ownerSession ?? null,
|
|
676
|
-
forced,
|
|
677
|
-
evidence: verdict?.terminalEvidence ?? null,
|
|
678
|
-
childAlive: verdict?.childAlive ?? null,
|
|
679
|
-
ownerAlive: verdict?.ownerAlive ?? null,
|
|
680
|
-
}, repo.mainRoot, lane.ownerSession)
|
|
681
|
-
}
|
|
682
|
-
return { settled: after.boundChild == null, lane: after, verdict, childId, forced }
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* The disputed-binding card (design D4): the reconciliation outcome leads
|
|
687
|
-
* and the only constructive choice is an explicit force-reclaim.
|
|
688
|
-
* @param {any} agent @param {any} lane @param {any} reconciliation @param {any} copy @param {any} repo @param {AbortSignal} [signal]
|
|
689
|
-
* @returns {Promise<boolean>} whether the user confirmed the force-reclaim
|
|
690
|
-
*/
|
|
691
|
-
async function askForceReclaim(agent, lane, reconciliation, copy, repo, signal) {
|
|
692
|
-
if (!deps.ask) return false
|
|
693
|
-
/** @type {any} */
|
|
694
|
-
let answer
|
|
695
|
-
try {
|
|
696
|
-
answer = await deps.ask(agent, [{
|
|
697
|
-
id: 'abandon',
|
|
698
|
-
header: copy.abandonHeader,
|
|
699
|
-
question: copy.abandonQuestion(lane.title),
|
|
700
|
-
detail: copy.abandonDisputedDetail(lane, lane.boundChild, reconciliation?.verdict ?? null, repo.mainRoot, residueOf(repo, lane)),
|
|
701
|
-
options: [
|
|
702
|
-
{ label: copy.forceReclaim, description: copy.forceReclaimDescription },
|
|
703
|
-
{ label: copy.cancel, description: copy.abandonDescriptions.cancel },
|
|
704
|
-
],
|
|
705
|
-
}], signal)
|
|
706
|
-
} catch {
|
|
707
|
-
return false
|
|
708
|
-
}
|
|
709
|
-
return answer?.answers?.find((/** @type {any} */ entry) => entry.id === 'abandon')?.selected?.[0] === copy.forceReclaim
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/**
|
|
713
|
-
* Reserve a lane for a child before it is spawned. Writers move the lane to
|
|
714
|
-
* `working` under the lock (a second writer is refused LANE_BUSY);
|
|
715
|
-
* read-only investigators leave the lane state alone.
|
|
716
|
-
* @param {any} parentSession @param {string} laneId @param {{ readOnly: boolean }} options
|
|
717
|
-
*/
|
|
718
|
-
async function prepareBind(parentSession, laneId, { readOnly }) {
|
|
719
|
-
const repo = await repoFor(cwdOf(parentSession))
|
|
720
|
-
const spec = (/** @type {any} */ lane) => ({ laneId: lane.id, lanePath: pathKey(lane.path), scope: lane.scope?.length ? lane.scope : null, readOnly })
|
|
721
|
-
if (readOnly) {
|
|
722
|
-
const lane = laneOf(repo, laneId)
|
|
723
|
-
if (!isActive(lane) && lane.state !== 'kept') throw new WorktreeError(WORKTREE_CODES.LANE_NOT_DISPATCHABLE, `lane ${laneId} is ${lane.state}`, { lane: laneId })
|
|
724
|
-
return { lane, repo, spec: spec(lane), contract: renderChildContract(lane, { readOnly: true }), commit: async () => {}, rollback: async () => {} }
|
|
725
|
-
}
|
|
726
|
-
const nonce = `pending:${randomUUID()}`
|
|
727
|
-
/** @type {any} */
|
|
728
|
-
let before
|
|
729
|
-
// Zombie-binding reconciliation (D1): a lane stuck in `working` on a dead
|
|
730
|
-
// binding settles first, so the bind proceeds from the lane's post-settle
|
|
731
|
-
// state; a standing refusal is re-thrown atomically inside apply below.
|
|
732
|
-
const stuck = laneOf(repo, laneId)
|
|
733
|
-
if (stuck.state === 'working' && stuck.boundChild) await reconcileBinding(repo, stuck, parentSession)
|
|
734
|
-
const lane = await apply(repo, laneId, { type: 'bind', patch: { boundChild: nonce }, code: WORKTREE_CODES.LANE_NOT_DISPATCHABLE }, (current) => {
|
|
735
|
-
if (current.state === 'working' && current.boundChild) throw new WorktreeError(WORKTREE_CODES.LANE_BUSY, `lane ${laneId} already has a bound worker (${current.boundChild})`, { lane: laneId, next: nextFor(current) })
|
|
736
|
-
before = current
|
|
737
|
-
})
|
|
738
|
-
return {
|
|
739
|
-
lane,
|
|
740
|
-
repo,
|
|
741
|
-
spec: spec(lane),
|
|
742
|
-
contract: renderChildContract(lane, { readOnly: false }),
|
|
743
|
-
/** @param {string} childId */
|
|
744
|
-
commit: async (childId) => {
|
|
745
|
-
await patchLane(repo, laneId, (current) => (current.boundChild === nonce ? { boundChild: childId } : {}))
|
|
746
|
-
},
|
|
747
|
-
rollback: async () => {
|
|
748
|
-
await repo.ledger.update((/** @type {any} */ ledger) => {
|
|
749
|
-
const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
|
|
750
|
-
if (index === -1 || ledger.lanes[index].boundChild !== nonce) return {}
|
|
751
|
-
ledger.lanes[index] = before
|
|
752
|
-
return { ledger }
|
|
753
|
-
})
|
|
754
|
-
},
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/**
|
|
759
|
-
* A bound writer settled: free the lane and run the host check. `silent`
|
|
760
|
-
* suppresses the immediate notice when the caller reports the outcome
|
|
761
|
-
* itself (foreground result / job result); background verification still
|
|
762
|
-
* notifies when it finishes.
|
|
763
|
-
* @param {string} childId @param {any} parentSession @param {{ silent?: boolean }} [options]
|
|
764
|
-
*/
|
|
765
|
-
async function childSettled(childId, parentSession, options = {}) {
|
|
766
|
-
let repo
|
|
767
|
-
try {
|
|
768
|
-
repo = await repoFor(cwdOf(parentSession))
|
|
769
|
-
} catch {
|
|
770
|
-
return null
|
|
771
|
-
}
|
|
772
|
-
const lane = repo.ledger.read().lanes.find((/** @type {any} */ entry) => entry.boundChild === childId)
|
|
773
|
-
if (!lane) return null
|
|
774
|
-
await patchLane(repo, lane.id, () => ({ boundChild: null }))
|
|
775
|
-
const checked = await runChecks(repo, lane.id, parentSession, { trigger: 'settle', silent: options.silent })
|
|
776
|
-
return { ...checked, notice: renderNotice(checked, checked.reason ?? undefined) }
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
// ─── checks ───────────────────────────────────────────────────────────
|
|
780
|
-
|
|
781
|
-
/**
|
|
782
|
-
* Mandatory preconditions, then (when configured) verification in the
|
|
783
|
-
* background. Returns the lane as of the precondition outcome.
|
|
784
|
-
* @param {any} repo @param {string} laneId @param {any} session @param {{ trigger: string, awaitVerification?: boolean, silent?: boolean }} options
|
|
785
|
-
*/
|
|
786
|
-
async function runChecks(repo, laneId, session, options) {
|
|
787
|
-
const tell = (/** @type {any} */ lane, /** @type {string} */ detail) => {
|
|
788
|
-
if (!options.silent) notifyOwner(lane, detail)
|
|
789
|
-
}
|
|
790
|
-
const lane = laneOf(repo, laneId)
|
|
791
|
-
if (!CHECKABLE.includes(lane.state)) return lane
|
|
792
|
-
if (lane.boundChild) {
|
|
793
|
-
// Zombie-binding reconciliation (D1) before the refusal: only terminal
|
|
794
|
-
// evidence ∧ both sides dead clears the binding (audited); the settle
|
|
795
|
-
// path then ran this very check, so return its outcome.
|
|
796
|
-
const outcome = await reconcileBinding(repo, lane, session)
|
|
797
|
-
if (outcome?.settled) return outcome.lane
|
|
798
|
-
const detail = outcome?.verdict ? ` (binding reconciliation: ${reconcileNote(outcome.verdict)}; the abandon confirmation card offers a force-reclaim)` : ''
|
|
799
|
-
throw new WorktreeError(WORKTREE_CODES.LANE_BUSY, `lane ${laneId} has a running worker; the host checks it when the worker settles${detail}`, { lane: laneId, next: nextFor(lane) })
|
|
800
|
-
}
|
|
801
|
-
const changes = await git.status(lane.path)
|
|
802
|
-
/** @type {{ to: string, reason: string } | null} */
|
|
803
|
-
let failed = null
|
|
804
|
-
if (changes.length > 0) failed = { to: 'dirty', reason: `${changes.length} uncommitted path(s): ${changes.slice(0, 5).map((entry) => entry.path).join(', ')}` }
|
|
805
|
-
else if ((await git.currentBranch(lane.path)) !== lane.branch) failed = { to: 'branch-moved', reason: `HEAD is not on ${lane.branch}` }
|
|
806
|
-
else if ((await git.aheadBehind(repo.mainRoot, lane.base.branch, lane.branch)).ahead === 0) failed = { to: 'no-commits', reason: `no commits ahead of ${lane.base.branch}` }
|
|
807
|
-
if (failed) {
|
|
808
|
-
const next = await apply(repo, laneId, { type: 'checked', to: failed.to, reason: failed.reason })
|
|
809
|
-
tell(next, failed.reason)
|
|
810
|
-
return next
|
|
811
|
-
}
|
|
812
|
-
const tree = await git.tree(lane.path)
|
|
813
|
-
/** @type {any} */
|
|
814
|
-
let repoConfig
|
|
815
|
-
try {
|
|
816
|
-
repoConfig = readConfig(repo)
|
|
817
|
-
} catch (error) {
|
|
818
|
-
const checking = await apply(repo, laneId, { type: 'checked', to: 'checking', patch: { check: { enabled: true, tree, results: [] } } })
|
|
819
|
-
const failedLane = await apply(repo, checking.id, { type: 'check-fail', reason: /** @type {any} */ (error)?.message ?? String(error) })
|
|
820
|
-
tell(failedLane, failedLane.reason)
|
|
821
|
-
return failedLane
|
|
822
|
-
}
|
|
823
|
-
if (repoConfig.check.length === 0) {
|
|
824
|
-
const landable = await apply(repo, laneId, { type: 'checked', to: 'landable', patch: { landableTree: tree, check: { enabled: false, tree } } })
|
|
825
|
-
tell(landable, 'mandatory checks passed; verification is not enabled for this repository')
|
|
826
|
-
return landable
|
|
827
|
-
}
|
|
828
|
-
const checking = await apply(repo, laneId, { type: 'checked', to: 'checking', patch: { check: { enabled: true, tree, results: [], startedAt: now() } } })
|
|
829
|
-
const run = runVerification(repo, laneId, repoConfig.check, session, tree)
|
|
830
|
-
if (options.awaitVerification) return run
|
|
831
|
-
void run
|
|
832
|
-
return checking
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
/** @param {any} repo @param {string} laneId @param {Array<{ name: string, run: string, timeoutSec: number }>} commands @param {any} session @param {string | null} tree */
|
|
836
|
-
async function runVerification(repo, laneId, commands, session, tree) {
|
|
837
|
-
const lanePath = laneOf(repo, laneId).path
|
|
838
|
-
/** @type {any[]} */
|
|
839
|
-
const results = []
|
|
840
|
-
/** @type {string | null} */
|
|
841
|
-
let failure = null
|
|
842
|
-
let tail = ''
|
|
843
|
-
if (!deps.shellRun) failure = `${WORKTREE_CODES.SHELL_UNAVAILABLE}: no shell executor is available for verification`
|
|
844
|
-
for (const [index, command] of commands.entries()) {
|
|
845
|
-
if (failure) break
|
|
846
|
-
const log = logFile(repo, laneId, `${index + 1}-${command.name.replace(/[^A-Za-z0-9_.-]+/g, '-')}`)
|
|
847
|
-
const started = now()
|
|
848
|
-
try {
|
|
849
|
-
const run = await /** @type {any} */ (deps.shellRun)({ command: command.run, cwd: lanePath, timeoutMs: command.timeoutSec * 1000, session })
|
|
850
|
-
writeFileSync(log, run.output)
|
|
851
|
-
results.push({ name: command.name, run: command.run, exit: run.code, ms: now() - started, log, denied: run.denied })
|
|
852
|
-
if (run.code !== 0) {
|
|
853
|
-
failure = `${command.name} ${run.timedOut ? 'timed out' : `exited ${run.code}`}${run.denied ? ' (sandbox denied)' : ''}`
|
|
854
|
-
tail = run.output.split(/\r?\n/).slice(-LOG_TAIL_LINES).join('\n')
|
|
855
|
-
}
|
|
856
|
-
} catch (error) {
|
|
857
|
-
results.push({ name: command.name, run: command.run, exit: -1, ms: now() - started, log })
|
|
858
|
-
failure = `${command.name} could not run: ${/** @type {any} */ (error)?.message ?? error}`
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
if (!failure) {
|
|
862
|
-
const after = await git.status(lanePath)
|
|
863
|
-
if (after.length > 0 || (await git.tree(lanePath)) !== tree) failure = 'verification modified lane content'
|
|
864
|
-
}
|
|
865
|
-
try {
|
|
866
|
-
const lane = failure
|
|
867
|
-
? await apply(repo, laneId, { type: 'check-fail', reason: failure, patch: { check: { enabled: true, tree, results, finishedAt: now() } } })
|
|
868
|
-
: await apply(repo, laneId, { type: 'check-pass', patch: { landableTree: tree, check: { enabled: true, tree, results, finishedAt: now() } } })
|
|
869
|
-
const last = results.at(-1)
|
|
870
|
-
notifyOwner(lane, failure ? `${failure}; log ${last?.log ?? 'n/a'}${tail ? `\n${tail}` : ''}` : `verification passed: ${results.map((entry) => entry.name).join(', ')}`)
|
|
871
|
-
return lane
|
|
872
|
-
} catch {
|
|
873
|
-
return null
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
/**
|
|
878
|
-
* worktree_check / /worktree check.
|
|
879
|
-
* @param {any} session @param {string} laneId @param {{ verificationOnly?: boolean, awaitVerification?: boolean }} [options]
|
|
880
|
-
*/
|
|
881
|
-
async function check(session, laneId, options = {}) {
|
|
882
|
-
const repo = await repoFor(cwdOf(session))
|
|
883
|
-
await refresh(repo)
|
|
884
|
-
const current = laneOf(repo, laneId)
|
|
885
|
-
if (options.verificationOnly && readConfig(repo).check.length === 0) {
|
|
886
|
-
throw new WorktreeError(WORKTREE_CODES.VERIFICATION_DISABLED, 'verification is not enabled for this repository (no "check" in the worktree config)', { lane: laneId, next: nextFor(current) })
|
|
887
|
-
}
|
|
888
|
-
if (!CHECKABLE.includes(current.state)) {
|
|
889
|
-
throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is ${current.state} and cannot be checked`, { lane: laneId, next: nextFor(current) })
|
|
890
|
-
}
|
|
891
|
-
const lane = await runChecks(repo, laneId, session, { trigger: 'check', awaitVerification: options.awaitVerification })
|
|
892
|
-
return result(lane, `lane ${lane.state}${lane.reason ? `: ${lane.reason}` : ''}`)
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
// ─── landing ──────────────────────────────────────────────────────────
|
|
896
|
-
|
|
897
|
-
/**
|
|
898
|
-
* worktree_land (agent asks the user) or /worktree land (the user's
|
|
899
|
-
* command is the approval).
|
|
900
|
-
* @param {any} agent - the calling main agent
|
|
901
|
-
* @param {string} laneId
|
|
902
|
-
* @param {{ userApproved?: boolean, signal?: AbortSignal }} [options]
|
|
903
|
-
*/
|
|
904
|
-
async function land(agent, laneId, options = {}) {
|
|
905
|
-
const session = agent?.session
|
|
906
|
-
const repo = await repoFor(cwdOf(session))
|
|
907
|
-
const { mainBranch } = await refresh(repo)
|
|
908
|
-
const lane = laneOf(repo, laneId)
|
|
909
|
-
if (!LANDABLE_FROM.includes(lane.state) || !lane.landableTree) {
|
|
910
|
-
throw new WorktreeError(WORKTREE_CODES.NOT_LANDABLE, `lane ${laneId} is ${lane.state}${lane.reason ? ` (${lane.reason})` : ''}; only a checked, landable lane can be merged`, { lane: laneId, next: nextFor(lane) })
|
|
911
|
-
}
|
|
912
|
-
const tree = await git.tree(lane.path)
|
|
913
|
-
if (tree !== lane.landableTree) throw new WorktreeError(WORKTREE_CODES.STALE_LANDABLE, `lane ${laneId} changed after it was checked`, { lane: laneId, next: { tool: 'worktree_check', args: { lane: laneId } } })
|
|
914
|
-
if (mainBranch !== lane.base.branch) {
|
|
915
|
-
throw new WorktreeError(WORKTREE_CODES.BASE_MOVED, `the main worktree is on ${mainBranch ?? 'a detached HEAD'}, not ${lane.base.branch}`, { lane: laneId, next: { waitFor: 'user', hint: `the user checks out ${lane.base.branch} in the main worktree` } })
|
|
916
|
-
}
|
|
917
|
-
const precheck = await git.mergeTreeCheck(repo.mainRoot, lane.base.branch, lane.branch)
|
|
918
|
-
if (!precheck.clean) {
|
|
919
|
-
const conflicted = await apply(repo, laneId, { type: 'conflict', reason: `conflicts with ${lane.base.branch}: ${precheck.conflicts.join(', ')}`, patch: { conflicts: precheck.conflicts } })
|
|
920
|
-
return result(conflicted, `not merged: ${precheck.conflicts.length} conflicting path(s)`, { conflicts: precheck.conflicts })
|
|
921
|
-
}
|
|
922
|
-
if (!(await git.indexEmpty(repo.mainRoot))) {
|
|
923
|
-
throw new WorktreeError(WORKTREE_CODES.MAIN_STAGED, 'the main worktree has staged changes; commit or unstage them before merging', { lane: laneId, next: { waitFor: 'user', hint: 'the user commits or unstages the staged changes' } })
|
|
924
|
-
}
|
|
925
|
-
const changed = await git.changedFiles(repo.mainRoot, lane.base.branch, lane.branch)
|
|
926
|
-
const dirty = (await git.status(repo.mainRoot)).map((entry) => entry.path)
|
|
927
|
-
const overlap = dirty.filter((path) => changed.includes(path))
|
|
928
|
-
if (overlap.length > 0) {
|
|
929
|
-
throw new WorktreeError(WORKTREE_CODES.MAIN_DIRTY_OVERLAP, `uncommitted changes in the main worktree touch lane files: ${overlap.join(', ')}`, { lane: laneId, data: { paths: overlap }, next: { waitFor: 'user', hint: 'the user commits or stashes those files' } })
|
|
930
|
-
}
|
|
931
|
-
const heads = { main: await git.revParse(repo.mainRoot, 'HEAD'), lane: await git.revParse(lane.path, 'HEAD') }
|
|
932
|
-
const stat = await git.diffStat(repo.mainRoot, lane.base.branch, lane.branch)
|
|
933
|
-
const by = options.userApproved ? 'user' : 'host'
|
|
934
|
-
const approveMode = approveModeSafe(agent?.session)
|
|
935
|
-
const auto = approveMode !== 'manual'
|
|
936
|
-
// Auto-approve mode (design D3): the approval card is bypassed entirely —
|
|
937
|
-
// no `awaiting-approval`, no ask funnel, no card-open re-verification
|
|
938
|
-
// window (the entry tree check above already bound the merge target).
|
|
939
|
-
// Every precheck before this point is shared with the manual path. The
|
|
940
|
-
// manual path below stays byte-identical.
|
|
941
|
-
if (!options.userApproved && !auto) {
|
|
942
|
-
const declined = (/** @type {string} */ reason, /** @type {string | undefined} */ feedback) => apply(repo, laneId, { type: 'decline', reason, patch: { decline: { reason, feedback: feedback ?? null, at: now() } } })
|
|
943
|
-
if (!deps.ask) {
|
|
944
|
-
await apply(repo, laneId, { type: 'ask' })
|
|
945
|
-
const lane2 = await declined('no user-question answerer is available')
|
|
946
|
-
return result(lane2, 'not merged: nobody could be asked for approval')
|
|
947
|
-
}
|
|
948
|
-
await apply(repo, laneId, { type: 'ask', patch: { asking: { pid, at: now() } } })
|
|
949
|
-
asking.add(laneId)
|
|
950
|
-
const commits = await git.commits(repo.mainRoot, lane.base.branch, lane.branch)
|
|
951
|
-
const copy = cardCopy(cardLocale(deps.localeOf?.(session?.id)))
|
|
952
|
-
const mergeLabel = copy.mergeOption(lane.base.branch)
|
|
953
|
-
/** @type {any} */
|
|
954
|
-
let answer
|
|
955
|
-
try {
|
|
956
|
-
answer = await deps.ask(agent, [{
|
|
957
|
-
id: 'merge',
|
|
958
|
-
header: copy.mergeHeader,
|
|
959
|
-
question: copy.mergeQuestion(lane.title, lane.base.branch),
|
|
960
|
-
detail: copy.mergeDetail({ lane, commits, stat, verification: lane.check }),
|
|
961
|
-
options: [{ label: mergeLabel, description: copy.mergeOptionDescription(lane.branch) }, { label: copy.notNow, description: copy.notNowDescription }],
|
|
962
|
-
}], options.signal)
|
|
963
|
-
} catch (error) {
|
|
964
|
-
const lane2 = await declined(`approval not given (${/** @type {any} */ (error)?.code ?? /** @type {any} */ (error)?.message ?? 'cancelled'})`)
|
|
965
|
-
return result(lane2, 'not merged: the approval card was dismissed or unavailable')
|
|
966
|
-
} finally {
|
|
967
|
-
asking.delete(laneId)
|
|
968
|
-
}
|
|
969
|
-
const reply = answer?.answers?.find((/** @type {any} */ entry) => entry.id === 'merge')
|
|
970
|
-
const approved = reply?.selected?.length === 1 && reply.selected[0] === mergeLabel && !reply.custom
|
|
971
|
-
if (!approved) {
|
|
972
|
-
const lane2 = await declined(reply?.custom ? 'the user replied instead of approving' : 'the user chose not to merge now', reply?.custom)
|
|
973
|
-
return result(lane2, 'not merged: the user did not approve', reply?.custom ? { feedback: reply.custom } : {})
|
|
974
|
-
}
|
|
975
|
-
const sameMain = (await git.revParse(repo.mainRoot, 'HEAD')) === heads.main && (await git.currentBranch(repo.mainRoot)) === lane.base.branch
|
|
976
|
-
const sameLane = (await git.revParse(lane.path, 'HEAD')) === heads.lane && (await git.tree(lane.path)) === lane.landableTree
|
|
977
|
-
if (!sameMain || !sameLane) {
|
|
978
|
-
await declined('the main worktree or the lane changed while the approval card was open')
|
|
979
|
-
throw new WorktreeError(WORKTREE_CODES.STALE_LANDABLE, 'the main worktree or the lane changed while the approval card was open; nothing was merged', { lane: laneId, next: { tool: 'worktree_check', args: { lane: laneId } } })
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
const merged = await git.mergeNoFf(repo.mainRoot, lane.branch, `merge(lane): ${lane.title} (${lane.id})`)
|
|
983
|
-
if (!merged.ok) {
|
|
984
|
-
const conflicted = await apply(repo, laneId, { type: 'conflict', reason: `merge failed and was aborted: ${merged.detail}` })
|
|
985
|
-
return result(conflicted, 'not merged: git refused the merge (aborted, main worktree unchanged)')
|
|
986
|
-
}
|
|
987
|
-
const landed = await apply(repo, laneId, { type: 'land', by, audit: auto ? { auto: true, approveMode } : undefined, patch: { land: { commit: merged.commit, at: now(), by, stat } } })
|
|
988
|
-
const diff = await git.diff(repo.mainRoot, `${merged.commit}^1`, merged.commit, 60_000).catch(() => '')
|
|
989
|
-
if (!auto) {
|
|
990
|
-
return result(landed, `merged ${lane.branch} into ${lane.base.branch} as ${merged.commit?.slice(0, 7)}`, { merge: { commit: merged.commit, stat }, diff })
|
|
991
|
-
}
|
|
992
|
-
// Auto cleanup after the merge (design D3): auto-keep → mode worktree,
|
|
993
|
-
// auto-clean → mode all. The existing cleanup() body runs unchanged —
|
|
994
|
-
// scratch sync first, never --force on an auto path, the Edit Lock residue
|
|
995
|
-
// probe feeding the summary. A blocked removal stays `landed` and is
|
|
996
|
-
// reported concisely with the typed --force retry instead of a false
|
|
997
|
-
// success (the user can clean up later).
|
|
998
|
-
const cleanupMode = approveMode === 'auto-keep' ? 'worktree' : 'all'
|
|
999
|
-
try {
|
|
1000
|
-
const cleaned = await cleanup(session, laneId, /** @type {'worktree' | 'all'} */ (cleanupMode), { by: 'host', auto: true, approveMode })
|
|
1001
|
-
return result(landed, `merged ${lane.branch} into ${lane.base.branch} as ${merged.commit?.slice(0, 7)}; auto cleanup (mode ${cleanupMode}): ${cleaned.summary}`, { merge: { commit: merged.commit, stat }, diff, cleanup: { state: cleaned.state, summary: cleaned.summary }, next: cleaned.next })
|
|
1002
|
-
} catch (error) {
|
|
1003
|
-
const message = String(/** @type {any} */ (error)?.message ?? error)
|
|
1004
|
-
const next = /** @type {any} */ (error)?.next ?? null
|
|
1005
|
-
return result(landed, `merged ${lane.branch} into ${lane.base.branch} as ${merged.commit?.slice(0, 7)}; auto cleanup blocked: ${message}`, { merge: { commit: merged.commit, stat }, diff, cleanup: { error: message, ...(next ? { next } : {}) } })
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
/**
|
|
1010
|
-
* The post-merge cleanup card (user chooses keep / worktree / all).
|
|
1011
|
-
* @param {any} agent @param {string} laneId @param {AbortSignal} [signal]
|
|
1012
|
-
*/
|
|
1013
|
-
async function askCleanup(agent, laneId, signal) {
|
|
1014
|
-
if (!deps.ask) return null
|
|
1015
|
-
const repo = await repoFor(cwdOf(agent.session))
|
|
1016
|
-
const lane = laneOf(repo, laneId)
|
|
1017
|
-
if (lane.state !== 'landed') return null
|
|
1018
|
-
const copy = cardCopy(cardLocale(deps.localeOf?.(agent.session?.id)))
|
|
1019
|
-
// Read-only dirty probe BEFORE the card: the card discloses the counts and
|
|
1020
|
-
// a destructive choice is then the user's informed explicit force removal.
|
|
1021
|
-
const dirty = existsSync(lane.path) ? await git.dirtyCounts(lane.path) : null
|
|
1022
|
-
const dirtyCard = hasDirtyContent(dirty)
|
|
1023
|
-
/** @type {any} */
|
|
1024
|
-
let answer
|
|
1025
|
-
try {
|
|
1026
|
-
answer = await deps.ask(agent, [{
|
|
1027
|
-
id: 'cleanup',
|
|
1028
|
-
header: copy.cleanupHeader,
|
|
1029
|
-
question: copy.cleanupQuestion(lane.title),
|
|
1030
|
-
detail: copy.cleanupDetail(lane, lane.land?.stat ?? { files: 0, added: 0, removed: 0 }, repo.mainRoot, residueOf(repo, lane), dirtyCard ? dirty : null),
|
|
1031
|
-
options: [
|
|
1032
|
-
{ label: copy.choices.keep, description: copy.cleanupDescriptions.keep },
|
|
1033
|
-
{ label: copy.choices.worktree, description: dirtyCard ? copy.cleanupDescriptionsDirty.worktree : copy.cleanupDescriptions.worktree },
|
|
1034
|
-
{ label: copy.choices.all, description: dirtyCard ? copy.cleanupDescriptionsDirty.all : copy.cleanupDescriptions.all },
|
|
1035
|
-
],
|
|
1036
|
-
}], signal)
|
|
1037
|
-
} catch {
|
|
1038
|
-
return null
|
|
1039
|
-
}
|
|
1040
|
-
const choice = answer?.answers?.find((/** @type {any} */ entry) => entry.id === 'cleanup')?.selected?.[0]
|
|
1041
|
-
const mode = Object.entries(copy.choices).find(([, label]) => label === choice)?.[0]
|
|
1042
|
-
if (!mode) return null
|
|
1043
|
-
return cleanup(agent.session, laneId, /** @type {'keep' | 'worktree' | 'all'} */ (mode), { by: 'user', ...(mode !== 'keep' && dirtyCard ? { force: true } : {}) })
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
// ─── cleanup / abandon ────────────────────────────────────────────────
|
|
1047
|
-
|
|
1048
|
-
/** Copy the lane's .weir scratch into the main repository. @param {any} repo @param {any} lane */
|
|
1049
|
-
function syncScratch(repo, lane) {
|
|
1050
|
-
const source = join(lane.path, '.weir')
|
|
1051
|
-
if (!existsSync(source)) return null
|
|
1052
|
-
const target = join(repo.mainRoot, '.weir', 'lanes', lane.id)
|
|
1053
|
-
mkdirSync(target, { recursive: true })
|
|
1054
|
-
cpSync(source, target, { recursive: true, force: true })
|
|
1055
|
-
return target
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
/**
|
|
1059
|
-
* Concise blocked-removal error: the reason names the dirty counts when the
|
|
1060
|
-
* probe sees them (never the raw git `fatal:` output), and `next` names the
|
|
1061
|
-
* exact typed `--force` retry — the in-product escape for a lane that cannot
|
|
1062
|
-
* be removed. Force stays the USER's privilege, so the hint directs whoever
|
|
1063
|
-
* reads it (the model included) to the user's typed command.
|
|
1064
|
-
* @param {any} lane @param {string} mode
|
|
1065
|
-
*/
|
|
1066
|
-
async function blockedRemovalError(lane, mode) {
|
|
1067
|
-
const dirty = existsSync(lane.path) ? await git.dirtyCounts(lane.path) : { modified: 0, untracked: 0 }
|
|
1068
|
-
const reason = dirty.modified + dirty.untracked > 0
|
|
1069
|
-
? `removal blocked: the worktree contains modified or untracked files (${dirty.modified} modified, ${dirty.untracked} untracked)`
|
|
1070
|
-
: 'removal blocked: git refused to remove the worktree (it may be locked)'
|
|
1071
|
-
return new WorktreeError(WORKTREE_CODES.REMOVE_BLOCKED, reason, {
|
|
1072
|
-
lane: lane.id,
|
|
1073
|
-
next: { waitFor: 'user', hint: `the user retries with the explicit force override: /worktree clean ${lane.id} ${mode} --force` },
|
|
1074
|
-
})
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
/**
|
|
1078
|
-
* Auto paths (design D3) reuse this exact body with `{ by: 'host', auto: true,
|
|
1079
|
-
* approveMode }`: the scratch sync, the removal (forced ONLY behind the
|
|
1080
|
-
* user's informed explicit choice — a disclosed card choice or a typed
|
|
1081
|
-
* `--force`, never an auto/model path), the residue warning and the
|
|
1082
|
-
* blocked-removal semantics are identical; only the audit payload gains the
|
|
1083
|
-
* auto marker.
|
|
1084
|
-
* @param {any} session @param {string} laneId @param {'keep' | 'worktree' | 'all'} mode @param {{ by?: string, auto?: boolean, approveMode?: string, force?: boolean }} [options]
|
|
1085
|
-
*/
|
|
1086
|
-
async function cleanup(session, laneId, mode, options = {}) {
|
|
1087
|
-
if (!['keep', 'worktree', 'all'].includes(mode)) throw new Error('cleanup mode must be keep, worktree, or all')
|
|
1088
|
-
const repo = await repoFor(cwdOf(session))
|
|
1089
|
-
const lane = laneOf(repo, laneId)
|
|
1090
|
-
const by = options.by ?? 'user'
|
|
1091
|
-
if (!['landed', 'kept', 'abandoned'].includes(lane.state)) {
|
|
1092
|
-
throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is ${lane.state}; cleanup applies to merged (or abandoned) lanes`, { lane: laneId, next: nextFor(lane) })
|
|
1093
|
-
}
|
|
1094
|
-
if (mode === 'keep') {
|
|
1095
|
-
if (lane.state !== 'landed') return result(lane, 'nothing to do: the worktree is already kept')
|
|
1096
|
-
const kept = await apply(repo, laneId, { type: 'keep', by, patch: { cleanup: { mode: 'keep', at: now(), by } } })
|
|
1097
|
-
return result(kept, `kept ${kept.path}`)
|
|
1098
|
-
}
|
|
1099
|
-
// Read-only residue probe BEFORE anything is removed (design D5): a
|
|
1100
|
-
// warning only, never a gate, and it never mutates the authority.
|
|
1101
|
-
const residue = residueOf(repo, lane)
|
|
1102
|
-
const scratch = existsSync(lane.path) ? syncScratch(repo, lane) : null
|
|
1103
|
-
// `force` reaches git only from an informed explicit user choice (a
|
|
1104
|
-
// disclosed card choice via askCleanup, or the typed `--force`); auto and
|
|
1105
|
-
// model paths never force.
|
|
1106
|
-
const force = options.force === true && options.auto !== true
|
|
1107
|
-
if (existsSync(lane.path)) {
|
|
1108
|
-
try {
|
|
1109
|
-
await git.worktreeRemove(repo.mainRoot, lane.path, { force })
|
|
1110
|
-
} catch (error) {
|
|
1111
|
-
if (!(error instanceof WorktreeError) || error.code !== WORKTREE_CODES.REMOVE_BLOCKED) throw error
|
|
1112
|
-
// State unchanged: the blocked removal is recorded on the lane (so it
|
|
1113
|
-
// stays retryable) and reported concisely with the --force escape.
|
|
1114
|
-
const blocked = await blockedRemovalError(lane, mode)
|
|
1115
|
-
await patchLane(repo, laneId, (current) => ({ cleanup: { ...(current.cleanup ?? {}), blocked: blocked.reason } }))
|
|
1116
|
-
throw blocked
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
let branchNote = ''
|
|
1120
|
-
if (mode === 'all' && (await git.branchExists(repo.mainRoot, lane.branch))) {
|
|
1121
|
-
try {
|
|
1122
|
-
await git.branchDelete(repo.mainRoot, lane.branch)
|
|
1123
|
-
branchNote = `; deleted ${lane.branch}`
|
|
1124
|
-
} catch (error) {
|
|
1125
|
-
branchNote = `; kept ${lane.branch} (${/** @type {any} */ (error)?.message ?? error})`
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
const record = { mode, at: now(), by, scratch }
|
|
1129
|
-
const next = lane.state === 'abandoned'
|
|
1130
|
-
? await patchLane(repo, laneId, () => ({ cleanup: record }))
|
|
1131
|
-
: await apply(repo, laneId, { type: 'clean', by, patch: { cleanup: record } })
|
|
1132
|
-
deps.audit('cleanup', { lane: laneId, mode, by, scratch, ...(force ? { forced: true } : {}), ...(options.auto === true ? { auto: true, approveMode: options.approveMode ?? null } : {}) }, repo.mainRoot, lane.ownerSession)
|
|
1133
|
-
return result(next, `removed ${lane.path}${branchNote}${scratch ? `; scratch copied to ${scratch}` : ''}${residue ? `; ${residueNote(residue)}` : ''}`)
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
/**
|
|
1137
|
-
* @param {any} agent @param {string} laneId
|
|
1138
|
-
* @param {{ mode?: 'keep' | 'worktree' | 'all', force?: boolean, signal?: AbortSignal }} [options] - a mode is the user's typed choice (command path); `force` is the typed `--force` override
|
|
1139
|
-
*/
|
|
1140
|
-
async function abandon(agent, laneId, options = {}) {
|
|
1141
|
-
const repo = await repoFor(cwdOf(agent?.session))
|
|
1142
|
-
let lane = laneOf(repo, laneId)
|
|
1143
|
-
if (!isActive(lane)) throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is already ${lane.state}`, { lane: laneId })
|
|
1144
|
-
// Zombie-binding reconciliation BEFORE the confirmation card (design
|
|
1145
|
-
// D1/D4): a binding with terminal evidence and no live side settles here
|
|
1146
|
-
// through the childSettled path; a standing one is disclosed on the card
|
|
1147
|
-
// with an explicit force-reclaim choice.
|
|
1148
|
-
/** @type {any} */
|
|
1149
|
-
let reconciliation = null
|
|
1150
|
-
if (lane.boundChild) {
|
|
1151
|
-
reconciliation = await reconcileBinding(repo, lane, agent?.session)
|
|
1152
|
-
if (reconciliation?.settled) lane = reconciliation.lane
|
|
1153
|
-
if (!isActive(lane)) throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is already ${lane.state}`, { lane: laneId })
|
|
1154
|
-
}
|
|
1155
|
-
const unmerged = (await git.branchExists(repo.mainRoot, lane.branch)) ? await git.unmergedCount(repo.mainRoot, lane.base.branch, lane.branch) : 0
|
|
1156
|
-
let mode = options.mode
|
|
1157
|
-
// Auto-approve path (design D3): no card, the mapped mode decides —
|
|
1158
|
-
// auto-keep removes the worktree and keeps the branch, auto-clean also
|
|
1159
|
-
// deletes it. Force-reclaim is NEVER auto: a binding the reconciliation
|
|
1160
|
-
// would not clear skips this branch entirely and falls back to the manual
|
|
1161
|
-
// card below (reconciliation outcome + force-reclaim choice) in EVERY mode.
|
|
1162
|
-
const approveMode = approveModeSafe(agent?.session)
|
|
1163
|
-
const auto = !mode && approveMode !== 'manual' && !lane.boundChild
|
|
1164
|
-
if (auto) mode = approveMode === 'auto-keep' ? 'worktree' : 'all'
|
|
1165
|
-
const copy = cardCopy(cardLocale(deps.localeOf?.(agent?.session?.id)))
|
|
1166
|
-
// Card-path facts: whether a destructive card choice is the user's informed
|
|
1167
|
-
// explicit force removal (the card disclosed the dirty counts).
|
|
1168
|
-
let cardChoice = false
|
|
1169
|
-
let dirtyCard = false
|
|
1170
|
-
if (!mode) {
|
|
1171
|
-
if (!deps.ask) throw new WorktreeError(WORKTREE_CODES.MAIN_AGENT_ONLY, 'abandoning needs the user\'s confirmation and no answerer is available')
|
|
1172
|
-
// A binding the reconciliation would not clear is the user's call
|
|
1173
|
-
// (design D4): the disputed card states the outcome, and the explicit
|
|
1174
|
-
// force-reclaim settles through the same path, audited as forced.
|
|
1175
|
-
if (lane.boundChild) {
|
|
1176
|
-
const reclaim = await askForceReclaim(agent, lane, reconciliation, copy, repo, options.signal)
|
|
1177
|
-
if (!reclaim) return result(lane, 'not abandoned: the user cancelled')
|
|
1178
|
-
reconciliation = await reconcileBinding(repo, lane, agent?.session, { forced: true })
|
|
1179
|
-
lane = laneOf(repo, laneId)
|
|
1180
|
-
if (lane.boundChild) throw new WorktreeError(WORKTREE_CODES.LANE_BUSY, `lane ${laneId} is still bound to ${lane.boundChild}; the force-reclaim could not settle it`, { lane: laneId })
|
|
1181
|
-
}
|
|
1182
|
-
// Read-only dirty probe BEFORE the card: the card discloses the counts
|
|
1183
|
-
// and the destructive options name the permanent destruction.
|
|
1184
|
-
const dirty = existsSync(lane.path) ? await git.dirtyCounts(lane.path) : null
|
|
1185
|
-
dirtyCard = hasDirtyContent(dirty)
|
|
1186
|
-
/** @type {any} */
|
|
1187
|
-
let answer
|
|
1188
|
-
try {
|
|
1189
|
-
answer = await deps.ask(agent, [{
|
|
1190
|
-
id: 'abandon',
|
|
1191
|
-
header: copy.abandonHeader,
|
|
1192
|
-
question: copy.abandonQuestion(lane.title),
|
|
1193
|
-
detail: copy.abandonDetail(lane, unmerged, repo.mainRoot, residueOf(repo, lane), reconciliation?.settled ? { child: reconciliation.childId, forced: reconciliation.forced } : null, dirtyCard ? dirty : null),
|
|
1194
|
-
options: [
|
|
1195
|
-
{ label: copy.choices.keep, description: copy.abandonDescriptions.keep },
|
|
1196
|
-
{ label: copy.choices.worktree, description: dirtyCard ? copy.abandonDescriptionsDirty.worktree : copy.abandonDescriptions.worktree },
|
|
1197
|
-
{ label: copy.choices.all, description: (dirtyCard ? copy.abandonDescriptionsDirty.all : copy.abandonDescriptions.all)(unmerged) },
|
|
1198
|
-
{ label: copy.cancel, description: copy.abandonDescriptions.cancel },
|
|
1199
|
-
],
|
|
1200
|
-
}], options.signal)
|
|
1201
|
-
} catch {
|
|
1202
|
-
return result(lane, 'not abandoned: the confirmation card was dismissed')
|
|
1203
|
-
}
|
|
1204
|
-
const choice = answer?.answers?.find((/** @type {any} */ entry) => entry.id === 'abandon')?.selected?.[0]
|
|
1205
|
-
mode = /** @type {any} */ (Object.entries(copy.choices).find(([, label]) => label === choice)?.[0])
|
|
1206
|
-
if (!mode) return result(lane, 'not abandoned: the user cancelled')
|
|
1207
|
-
cardChoice = true
|
|
1208
|
-
}
|
|
1209
|
-
if (lane.boundChild) {
|
|
1210
|
-
const detail = reconciliation?.verdict ? ` (binding reconciliation: ${reconcileNote(reconciliation.verdict)}; call worktree_abandon without a mode and use the card's force-reclaim)` : ''
|
|
1211
|
-
throw new WorktreeError(WORKTREE_CODES.LANE_BUSY, `lane ${laneId} has a running worker; stop it first${detail}`, { lane: laneId })
|
|
1212
|
-
}
|
|
1213
|
-
// Removal is forced ONLY behind the user's informed explicit choice: a
|
|
1214
|
-
// destructive choice on the dirty-disclosing card, or the typed `--force`.
|
|
1215
|
-
// Typed-mode and auto paths (auto never reaches the card) never force.
|
|
1216
|
-
const force = (options.force === true && !auto) || (cardChoice && dirtyCard)
|
|
1217
|
-
const auditExtra = { ...(auto ? { auto: true, approveMode } : {}), ...(force ? { forced: true } : {}) }
|
|
1218
|
-
const abandoned = await apply(repo, laneId, { type: 'abandon', by: auto ? 'host' : 'user', reason: auto ? `abandoned automatically (${approveMode} mode)` : 'abandoned by the user', audit: Object.keys(auditExtra).length > 0 ? auditExtra : undefined, patch: { cleanup: { mode, at: now(), by: auto ? 'host' : 'user' } } })
|
|
1219
|
-
if (mode === 'keep') return result(abandoned, `abandoned; ${abandoned.path} and ${abandoned.branch} are kept`)
|
|
1220
|
-
// Read-only residue probe BEFORE anything is removed (design D5): a
|
|
1221
|
-
// warning only, never a gate, and it never mutates the authority. The
|
|
1222
|
-
// confirmation card already carried the warning when there was one.
|
|
1223
|
-
const residue = residueOf(repo, lane)
|
|
1224
|
-
const scratch = existsSync(lane.path) ? syncScratch(repo, lane) : null
|
|
1225
|
-
if (existsSync(lane.path)) {
|
|
1226
|
-
try {
|
|
1227
|
-
await git.worktreeRemove(repo.mainRoot, lane.path, { force })
|
|
1228
|
-
} catch (error) {
|
|
1229
|
-
if (!(error instanceof WorktreeError) || error.code !== WORKTREE_CODES.REMOVE_BLOCKED) throw error
|
|
1230
|
-
// Abandoned, but the worktree stays: recorded as kept+blocked so a
|
|
1231
|
-
// later cleanup retries once the user types the explicit override.
|
|
1232
|
-
const blocked = await blockedRemovalError(lane, mode)
|
|
1233
|
-
await patchLane(repo, laneId, (current) => ({ cleanup: { ...current.cleanup, mode: 'keep', blocked: blocked.reason } }))
|
|
1234
|
-
throw blocked
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
let branchNote = ''
|
|
1238
|
-
if (mode === 'all' && (await git.branchExists(repo.mainRoot, lane.branch))) {
|
|
1239
|
-
await git.branchDelete(repo.mainRoot, lane.branch, { force: unmerged > 0 })
|
|
1240
|
-
branchNote = `; deleted ${lane.branch}${unmerged > 0 ? ` (${unmerged} unmerged commit(s) discarded)` : ''}`
|
|
1241
|
-
}
|
|
1242
|
-
const final = await patchLane(repo, laneId, (current) => ({ cleanup: { ...current.cleanup, scratch } }))
|
|
1243
|
-
return result(final, `abandoned; removed ${lane.path}${branchNote}${residue ? `; ${residueNote(residue)}` : ''}`)
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
// ─── watches (lane state subscriptions) ────────────────────────────────
|
|
1247
|
-
|
|
1248
|
-
/**
|
|
1249
|
-
* worktree_watch: subscribe the calling session to conclusion states of one
|
|
1250
|
-
* lane in this repository (including lanes another session opened). One-shot:
|
|
1251
|
-
* the first target state hit — or the expiry — delivers exactly one notice
|
|
1252
|
-
* to the SUBSCRIBER and removes the watch. Re-subscribing the same lane
|
|
1253
|
-
* replaces the old watch. A lane already in a target state hits immediately
|
|
1254
|
-
* (delivered once, never stored). The lifetime comes from the
|
|
1255
|
-
* worktreeWatchTimeoutMinutes setting, frozen into `expiresAt` at subscribe
|
|
1256
|
-
* time; the model cannot override it (no timeout parameter).
|
|
1257
|
-
* @param {any} session - the subscribing (main) session
|
|
1258
|
-
* @param {{ lane: string, states: string[] }} args
|
|
1259
|
-
*/
|
|
1260
|
-
async function watch(session, args) {
|
|
1261
|
-
const states = normalizeWatchStates(args?.states)
|
|
1262
|
-
const laneId = typeof args?.lane === 'string' ? args.lane : ''
|
|
1263
|
-
const sessionId = session?.id
|
|
1264
|
-
if (typeof sessionId !== 'string' || !sessionId) throw new Error('worktree_watch: the subscribing session has no id')
|
|
1265
|
-
const repo = await repoFor(cwdOf(session))
|
|
1266
|
-
// Reconcile first so the immediate-hit test below reads the lane's true
|
|
1267
|
-
// state (and so expired watches are pruned before this one is stored).
|
|
1268
|
-
await refresh(repo)
|
|
1269
|
-
const timeoutMinutes = watchTimeoutMinutesOf(config())
|
|
1270
|
-
const entry = createWatch({ id: randomUUID(), laneId, sessionId, states, now: now(), timeoutMinutes })
|
|
1271
|
-
// Check-vs-insert is atomic inside the ledger lock: a lane that reaches a
|
|
1272
|
-
// target state concurrently either hits BEFORE (immediate path) or AFTER
|
|
1273
|
-
// (the transition's own hit scan) this subscribe — never both, never none.
|
|
1274
|
-
const outcome = await repo.ledger.update((/** @type {any} */ ledger) => {
|
|
1275
|
-
const current = ledger.lanes.find((/** @type {any} */ record) => record.id === laneId)
|
|
1276
|
-
if (!current) throw new WorktreeError(WORKTREE_CODES.UNKNOWN_LANE, `no lane "${laneId}" in ${repo.rootPath}`)
|
|
1277
|
-
if (states.includes(current.state)) return { result: { lane: current, immediate: true, replaced: null } }
|
|
1278
|
-
const replaced = upsertWatch(ledger, entry)
|
|
1279
|
-
return { ledger, result: { lane: current, immediate: false, replaced } }
|
|
1280
|
-
})
|
|
1281
|
-
const { lane, immediate, replaced } = /** @type {any} */ (outcome)
|
|
1282
|
-
if (replaced) disarmWatch(replaced.id)
|
|
1283
|
-
if (immediate) {
|
|
1284
|
-
deps.audit('watch', { lane: lane.id, sessionId, states, outcome: 'hit-immediate' }, repo.mainRoot, sessionId)
|
|
1285
|
-
try {
|
|
1286
|
-
deps.notify(sessionId, renderWatchHit(lane))
|
|
1287
|
-
} catch (error) {
|
|
1288
|
-
deps.logger?.warn?.(`worktree: watch-hit notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
1289
|
-
}
|
|
1290
|
-
return result(lane, `already ${lane.state}; the watch fired immediately and was not stored`, { hit: lane.state })
|
|
1291
|
-
}
|
|
1292
|
-
armWatch(repo, entry)
|
|
1293
|
-
deps.audit('watch', { lane: lane.id, sessionId, states, expiresAt: entry.expiresAt, replaced: replaced?.id ?? null, outcome: 'subscribed' }, repo.mainRoot, sessionId)
|
|
1294
|
-
return result(lane, `watching for ${states.join(', ')} until ${new Date(entry.expiresAt).toISOString()}`, { watch: { lane: lane.id, states: entry.states, expiresAt: entry.expiresAt } })
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
// ─── views ────────────────────────────────────────────────────────────
|
|
1298
|
-
|
|
1299
|
-
/** Legal actions per lane for the panel (disabled ones carry the reason). @param {any} lane */
|
|
1300
|
-
function actionsFor(lane) {
|
|
1301
|
-
const deny = (/** @type {string} */ reason) => ({ enabled: false, reason })
|
|
1302
|
-
const allow = { enabled: true }
|
|
1303
|
-
const exists = !['cleaned'].includes(lane.state) && !(lane.state === 'abandoned' && lane.cleanup?.mode !== 'keep')
|
|
1304
|
-
return {
|
|
1305
|
-
diff: lane.state === 'preparing' ? deny('setup is still running') : allow,
|
|
1306
|
-
check: !CHECKABLE.includes(lane.state) ? deny(`not available while ${lane.state}`) : lane.boundChild ? deny('a worker is running in this lane') : allow,
|
|
1307
|
-
land: !LANDABLE_FROM.includes(lane.state) ? deny(`only a landable lane can be merged (now ${lane.state})`) : lane.baseMoved ? deny(`the main worktree is not on ${lane.base.branch}`) : allow,
|
|
1308
|
-
clean: ['landed', 'kept'].includes(lane.state) || (lane.state === 'abandoned' && lane.cleanup?.mode === 'keep') ? allow : deny('cleanup applies after merging'),
|
|
1309
|
-
abandon: isActive(lane) ? (lane.boundChild ? deny('a worker is running in this lane') : allow) : deny(`already ${lane.state}`),
|
|
1310
|
-
setup: lane.state === 'setup-failed' ? allow : deny('setup only reruns after a failure'),
|
|
1311
|
-
copyPath: exists ? allow : deny('the worktree was removed'),
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
/**
|
|
1316
|
-
* Full read-only view for the GUI panel.
|
|
1317
|
-
* @param {any} session
|
|
1318
|
-
* @param {{ mode?: boolean, approve?: string | null, approveSource?: 'session' | 'global' }} [options] - cold-read override: a boolean
|
|
1319
|
-
* `mode` replaces the live projection read (a pseudo session folded from
|
|
1320
|
-
* the persisted log by sessionQuery has no live projection cells); `approve`
|
|
1321
|
-
* and `approveSource` carry the cold-folded auto-approve override the same
|
|
1322
|
-
* way (null = no override → the global default answers).
|
|
1323
|
-
*/
|
|
1324
|
-
async function view(session, options) {
|
|
1325
|
-
const viewMode = () => (typeof options?.mode === 'boolean' ? options.mode : safeMode(session))
|
|
1326
|
-
const viewApproveMode = () => (options && typeof options.approve === 'string' ? options.approve : approveModeSafe(session))
|
|
1327
|
-
const viewApproveModeSource = () => (options && (options.approveSource === 'session' || options.approveSource === 'global') ? options.approveSource : approveModeSourceSafe(session))
|
|
1328
|
-
let repo
|
|
1329
|
-
try {
|
|
1330
|
-
repo = await repoFor(cwdOf(session))
|
|
1331
|
-
} catch (error) {
|
|
1332
|
-
const known = error instanceof WorktreeError
|
|
1333
|
-
return { available: false, mode: viewMode(), approveMode: viewApproveMode(), approveModeSource: viewApproveModeSource(), error: known ? error.toJSON() : { code: 'ERROR', message: String(/** @type {any} */ (error)?.message ?? error) } }
|
|
1334
|
-
}
|
|
1335
|
-
try {
|
|
1336
|
-
const { mainBranch, unmanaged } = await refresh(repo)
|
|
1337
|
-
const ledger = repo.ledger.read()
|
|
1338
|
-
/** @type {any} */
|
|
1339
|
-
let verification
|
|
1340
|
-
try {
|
|
1341
|
-
const repoConfig = readConfig(repo)
|
|
1342
|
-
verification = { enabled: repoConfig.check.length > 0, commands: repoConfig.check.map((entry) => entry.name), setup: repoConfig.setup }
|
|
1343
|
-
} catch (error) {
|
|
1344
|
-
verification = { enabled: true, commands: [], error: String(/** @type {any} */ (error)?.message ?? error) }
|
|
1345
|
-
}
|
|
1346
|
-
const lanes = []
|
|
1347
|
-
for (const lane of [...ledger.lanes].reverse()) {
|
|
1348
|
-
const exists = existsSync(lane.path)
|
|
1349
|
-
let counts = null
|
|
1350
|
-
let stat = null
|
|
1351
|
-
if ((await git.branchExists(repo.mainRoot, lane.branch).catch(() => false))) {
|
|
1352
|
-
counts = await git.aheadBehind(repo.mainRoot, lane.base.branch, lane.branch).catch(() => null)
|
|
1353
|
-
stat = await git.diffStat(repo.mainRoot, lane.base.branch, lane.branch).catch(() => null)
|
|
1354
|
-
}
|
|
1355
|
-
lanes.push({
|
|
1356
|
-
...lane,
|
|
1357
|
-
history: (lane.history ?? []).slice(-12),
|
|
1358
|
-
exists,
|
|
1359
|
-
ahead: counts?.ahead ?? null,
|
|
1360
|
-
behind: counts?.behind ?? null,
|
|
1361
|
-
stat,
|
|
1362
|
-
next: nextFor(lane),
|
|
1363
|
-
transient: TRANSIENT.includes(lane.state),
|
|
1364
|
-
...watchFacts(ledger.watches, lane.id),
|
|
1365
|
-
actions: actionsFor(lane),
|
|
1366
|
-
})
|
|
1367
|
-
}
|
|
1368
|
-
return {
|
|
1369
|
-
available: true,
|
|
1370
|
-
mode: viewMode(),
|
|
1371
|
-
approveMode: viewApproveMode(),
|
|
1372
|
-
approveModeSource: viewApproveModeSource(),
|
|
1373
|
-
ownedBySession: lanes.filter((lane) => lane.ownerSession === session?.id).map((lane) => lane.id),
|
|
1374
|
-
repo: { mainRoot: repo.mainRoot, root: repo.root, rootPath: repo.rootPath, branch: mainBranch, gitVersion: repo.version?.join('.') ?? null, exclude: hasExclude(repo.commonDir, repo.root), verification },
|
|
1375
|
-
lanes,
|
|
1376
|
-
unmanaged,
|
|
1377
|
-
}
|
|
1378
|
-
} catch (error) {
|
|
1379
|
-
const known = error instanceof WorktreeError
|
|
1380
|
-
return { available: false, mode: viewMode(), approveMode: viewApproveMode(), approveModeSource: viewApproveModeSource(), error: known ? error.toJSON() : { code: 'ERROR', message: String(/** @type {any} */ (error)?.message ?? error) } }
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
/** @param {any} session */
|
|
1385
|
-
function safeMode(session) {
|
|
1386
|
-
try {
|
|
1387
|
-
return deps.modeOf(session) === true
|
|
1388
|
-
} catch {
|
|
1389
|
-
return false
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
/** @param {any} session @param {string} laneId */
|
|
1394
|
-
async function diffOf(session, laneId) {
|
|
1395
|
-
const repo = await repoFor(cwdOf(session))
|
|
1396
|
-
const lane = laneOf(repo, laneId)
|
|
1397
|
-
if (lane.land?.commit) return git.diff(repo.mainRoot, `${lane.land.commit}^1`, lane.land.commit)
|
|
1398
|
-
return git.diff(repo.mainRoot, lane.base.branch, lane.branch)
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
/** Runtime-context board (sync; empty until the repo resolved once). @param {any} session */
|
|
1402
|
-
function board(session) {
|
|
1403
|
-
const repo = repoCached(session)
|
|
1404
|
-
const mode = safeMode(session)
|
|
1405
|
-
if (!repo) return mode ? renderBoard({ lanes: [], mode }) : ''
|
|
1406
|
-
try {
|
|
1407
|
-
const ledger = repo.ledger.read()
|
|
1408
|
-
return renderBoard({ lanes: ledger.lanes, watches: ledger.watches, mode })
|
|
1409
|
-
} catch {
|
|
1410
|
-
return ''
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
/** Lane ids this session owns / active counts for quick status. @param {any} session */
|
|
1415
|
-
function summaryOf(session) {
|
|
1416
|
-
const repo = repoCached(session)
|
|
1417
|
-
if (!repo) return null
|
|
1418
|
-
try {
|
|
1419
|
-
const lanes = repo.ledger.read().lanes.filter((/** @type {any} */ lane) => lane.ownerSession === session?.id)
|
|
1420
|
-
return { active: lanes.filter(isActive).length, awaiting: lanes.filter((/** @type {any} */ lane) => lane.state === 'awaiting-approval').length }
|
|
1421
|
-
} catch {
|
|
1422
|
-
return null
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
// ─── repository config ────────────────────────────────────────────────
|
|
1427
|
-
|
|
1428
|
-
/** /worktree init: current config + detected suggestions (never written here). @param {any} session */
|
|
1429
|
-
async function initSuggestions(session) {
|
|
1430
|
-
const repo = await repoFor(cwdOf(session))
|
|
1431
|
-
let current = null
|
|
1432
|
-
let error = null
|
|
1433
|
-
try {
|
|
1434
|
-
current = readConfig(repo)
|
|
1435
|
-
} catch (caught) {
|
|
1436
|
-
error = String(/** @type {any} */ (caught)?.message ?? caught)
|
|
1437
|
-
}
|
|
1438
|
-
const files = readdirSync(repo.mainRoot)
|
|
1439
|
-
let packageJson
|
|
1440
|
-
try {
|
|
1441
|
-
packageJson = JSON.parse(readFileSync(join(repo.mainRoot, 'package.json'), 'utf8'))
|
|
1442
|
-
} catch {
|
|
1443
|
-
packageJson = undefined
|
|
1444
|
-
}
|
|
1445
|
-
// The setup suggestion must be an invocation that works on THIS host
|
|
1446
|
-
// (bundled-runtime resolution), not a bare manager name — the user may
|
|
1447
|
-
// accept it verbatim into the config.
|
|
1448
|
-
let setup = null
|
|
1449
|
-
let setupWarning = null
|
|
1450
|
-
const manager = setupManagerFor(files)
|
|
1451
|
-
if (manager) {
|
|
1452
|
-
const derived = await deriveSetup(manager)
|
|
1453
|
-
if ('error' in derived) {
|
|
1454
|
-
setup = bareSetupCommand(manager)
|
|
1455
|
-
setupWarning = setupMissingReason(manager).replace('setup needs', 'suggestion note:')
|
|
1456
|
-
} else {
|
|
1457
|
-
setup = derived.command
|
|
1458
|
-
}
|
|
1459
|
-
}
|
|
1460
|
-
return { file: join(repo.rootPath, CONFIG_FILE), current, error, suggested: { setup, setupWarning, check: suggestChecks({ packageJson, fileNames: files }) } }
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
/** Write the repository config after the user confirmed it. @param {any} session @param {any} value */
|
|
1464
|
-
async function writeConfig(session, value) {
|
|
1465
|
-
const repo = await repoFor(cwdOf(session))
|
|
1466
|
-
const parsed = parseRepoConfig(value)
|
|
1467
|
-
ensureExclude(repo.commonDir, repo.root)
|
|
1468
|
-
mkdirSync(repo.rootPath, { recursive: true })
|
|
1469
|
-
const file = join(repo.rootPath, CONFIG_FILE)
|
|
1470
|
-
writeFileSync(file, `${JSON.stringify({ ...(parsed.setup ? { setup: parsed.setup } : {}), check: parsed.check }, null, 2)}\n`)
|
|
1471
|
-
return { file, config: parsed }
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
/** /worktree reconcile [--rebuild] @param {any} session @param {{ rebuild?: boolean }} [options] */
|
|
1475
|
-
async function reconcileCommand(session, options = {}) {
|
|
1476
|
-
const repo = await repoFor(cwdOf(session))
|
|
1477
|
-
if (options.rebuild) {
|
|
1478
|
-
const worktrees = await git.worktreeList(repo.mainRoot)
|
|
1479
|
-
const prefix = `${pathKey(repo.rootPath)}/`
|
|
1480
|
-
const at = now()
|
|
1481
|
-
const lanes = worktrees
|
|
1482
|
-
.filter((entry) => pathKey(entry.path).startsWith(prefix) && entry.branch?.startsWith('weir/'))
|
|
1483
|
-
.map((entry) => {
|
|
1484
|
-
const id = /** @type {string} */ (entry.branch).slice('weir/'.length)
|
|
1485
|
-
return {
|
|
1486
|
-
id, title: id, path: entry.path, branch: /** @type {string} */ (entry.branch), base: { branch: 'main', commit: null }, scope: [], state: 'working',
|
|
1487
|
-
ownerSession: session?.id ?? null, boundChild: null, baseMoved: false, landableTree: null, setup: { status: 'unknown' }, check: null, land: null,
|
|
1488
|
-
cleanup: null, reason: 'rebuilt from git; base assumed from the main worktree branch', createdAt: at, updatedAt: at, history: [],
|
|
1489
|
-
}
|
|
1490
|
-
})
|
|
1491
|
-
const mainBranch = await git.currentBranch(repo.mainRoot)
|
|
1492
|
-
for (const lane of lanes) lane.base.branch = mainBranch ?? 'main'
|
|
1493
|
-
const seq = lanes.reduce((max, lane) => Math.max(max, Number(/-(\d+)$/.exec(lane.id)?.[1] ?? 0)), 0)
|
|
1494
|
-
await repo.ledger.replace({ schemaVersion: 1, seq, lanes })
|
|
1495
|
-
deps.audit('reconcile', { rebuilt: lanes.map((lane) => lane.id) }, repo.mainRoot, session?.id ?? null)
|
|
1496
|
-
return { rebuilt: lanes.map((lane) => lane.id), unmanaged: [] }
|
|
1497
|
-
}
|
|
1498
|
-
const outcome = await refresh(repo)
|
|
1499
|
-
return { rebuilt: null, unmanaged: outcome.unmanaged }
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
/** Normalized absolute path of a tool argument relative to a session cwd. @param {string} cwd @param {string} path */
|
|
1503
|
-
function resolveArgPath(cwd, path) {
|
|
1504
|
-
return pathKey(isAbsolute(path) ? path : resolvePath(cwd, path))
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
/** The resolved global auto-approve default (command echo; never a raw invalid value). */
|
|
1508
|
-
const approveGlobalSafe = () => {
|
|
1509
|
-
try {
|
|
1510
|
-
return deps.approveGlobal?.() ?? 'auto-clean'
|
|
1511
|
-
} catch {
|
|
1512
|
-
return 'auto-clean'
|
|
1513
|
-
}
|
|
1514
|
-
}
|
|
18
|
+
const core = createLaneCore(deps)
|
|
19
|
+
const { repoFor, refresh, childSettled, check } = core
|
|
20
|
+
const {
|
|
21
|
+
open, setup, prepareBind, land, askCleanup, cleanup, abandon, watch,
|
|
22
|
+
initSuggestions, writeConfig, reconcile: reconcileCommand, resolveArgPath,
|
|
23
|
+
approveGlobal: approveGlobalSafe,
|
|
24
|
+
} = createLaneWorkflows(core)
|
|
25
|
+
const { view, diffOf, board, summaryOf, actionsFor } = createLaneViews(core)
|
|
1515
26
|
return {
|
|
1516
27
|
repoFor, refresh, open, setup, prepareBind, childSettled, check, land, askCleanup, cleanup, abandon, watch,
|
|
1517
28
|
view, diffOf, board, summaryOf, initSuggestions, writeConfig, reconcile: reconcileCommand, resolveArgPath, actionsFor,
|