weir-harness 1.1.3 → 1.1.4
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/package.json +1 -1
- package/src/edit-lock/remote.js +125 -15
- package/src/worktree/pkgmgr.js +49 -10
- package/src/worktree/runner.js +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weir-harness",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Weir \u2014 orchestrator-centric DSH agent preset: intent gate, category delegation, todo driver, pull-based background jobs, auto-compaction, hashline edit.",
|
|
6
6
|
"license": "MIT",
|
package/src/edit-lock/remote.js
CHANGED
|
@@ -30,6 +30,76 @@ export function endpointFor(directory, platform = process.platform) {
|
|
|
30
30
|
* @param {string} endpoint */
|
|
31
31
|
const namedPipe = endpoint => endpoint.startsWith('\\\\.\\pipe\\') || endpoint.startsWith('\\\\?\\pipe\\')
|
|
32
32
|
|
|
33
|
+
/** Channel-open arbitration for one endpoint (duplicate-binding recovery,
|
|
34
|
+
* design D1/D2/D4). One sessionId has at most one registered channel at any
|
|
35
|
+
* moment:
|
|
36
|
+
*
|
|
37
|
+
* - a prior binding whose socket is PROVEN dead (synchronous state bits only:
|
|
38
|
+
* `destroyed`, `closed` or `errored` — never an active probe) is reclaimed
|
|
39
|
+
* through the SAME disconnect path a processed EOF takes (revoke + forget;
|
|
40
|
+
* no lock is ever released), then the new open proceeds;
|
|
41
|
+
* - a prior binding that is alive — or whose state is in any way unclear — is
|
|
42
|
+
* refused, fail-closed, and is never disconnected;
|
|
43
|
+
* - with no prior channel binding, a refusal from `lifecycle.start` itself
|
|
44
|
+
* (a local agent or another mount generation holds the session) passes
|
|
45
|
+
* through untouched: a local binding can never be preempted from here.
|
|
46
|
+
*
|
|
47
|
+
* Opens of one sessionId are serialized through a per-session promise queue,
|
|
48
|
+
* because the reclaim-check-register sequence awaits; different sessionIds
|
|
49
|
+
* stay fully concurrent. Exported for the unit tests; production wiring is
|
|
50
|
+
* the endpoint below. */
|
|
51
|
+
export function createChannelBindingArbitration() {
|
|
52
|
+
/** @typedef {{destroyed?: boolean, closed?: boolean, errored?: unknown}} ChannelSocket */
|
|
53
|
+
/** @typedef {{disconnect: () => Promise<unknown>}} ChannelPeer */
|
|
54
|
+
/** @type {Map<string, {socket: ChannelSocket, peer: ChannelPeer}>} */
|
|
55
|
+
const bound = new Map()
|
|
56
|
+
/** @type {Map<string, Promise<unknown>>} */
|
|
57
|
+
const opening = new Map()
|
|
58
|
+
/** @param {string} sessionId
|
|
59
|
+
* @param {ChannelSocket} socket the NEW connection's socket
|
|
60
|
+
* @param {() => Promise<{peer: ChannelPeer}>} bind establish the channel
|
|
61
|
+
* (create the peer and start the lifecycle); its result is returned verbatim
|
|
62
|
+
* @returns {Promise<any>} */
|
|
63
|
+
function open(sessionId, socket, bind) {
|
|
64
|
+
const queued = opening.get(sessionId) ?? Promise.resolve()
|
|
65
|
+
const run = queued.then(step, step)
|
|
66
|
+
opening.set(sessionId, run)
|
|
67
|
+
const settled = () => { if (opening.get(sessionId) === run) opening.delete(sessionId) }
|
|
68
|
+
run.then(settled, settled)
|
|
69
|
+
return run
|
|
70
|
+
async function step() {
|
|
71
|
+
const prior = bound.get(sessionId)
|
|
72
|
+
if (prior) {
|
|
73
|
+
const dead = prior.socket.destroyed === true || prior.socket.closed === true || prior.socket.errored != null
|
|
74
|
+
if (!dead) throw new Error('session already bound')
|
|
75
|
+
try { await prior.peer.disconnect() } catch {
|
|
76
|
+
// Reclaim outcome unknown: stay fail-closed and KEEP the mapping, so
|
|
77
|
+
// the next open retries the reclaim instead of wedging behind a
|
|
78
|
+
// lifecycle-level refusal.
|
|
79
|
+
throw new Error('session already bound')
|
|
80
|
+
}
|
|
81
|
+
if (bound.get(sessionId) === prior) bound.delete(sessionId)
|
|
82
|
+
}
|
|
83
|
+
const established = await bind()
|
|
84
|
+
// Registered even when this socket already died during registration: the
|
|
85
|
+
// transport's own close handling still revokes it, and the next open
|
|
86
|
+
// reclaims the entry — a lost EOF can never wedge the session again.
|
|
87
|
+
bound.set(sessionId, { socket, peer: established.peer })
|
|
88
|
+
return established
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Index hygiene on transport close: drop the entry only while it still
|
|
92
|
+
* names THIS connection's socket — a reclaimed binding must never be removed
|
|
93
|
+
* by the late close of the dead socket it replaced. Revocation itself runs
|
|
94
|
+
* on the transport's own disconnect path, not here.
|
|
95
|
+
* @param {string} sessionId @param {ChannelSocket} socket */
|
|
96
|
+
function release(sessionId, socket) {
|
|
97
|
+
const prior = bound.get(sessionId)
|
|
98
|
+
if (prior && prior.socket === socket) bound.delete(sessionId)
|
|
99
|
+
}
|
|
100
|
+
return Object.freeze({ open, release })
|
|
101
|
+
}
|
|
102
|
+
|
|
33
103
|
/** Publisher side: one session channel per connection. The first frame must be
|
|
34
104
|
* `open {sessionId}`; the channel agent is fixed afterwards and EOF revokes it.
|
|
35
105
|
* Call only while holding the publisher reservation (stale socket removal).
|
|
@@ -46,26 +116,37 @@ export async function serveEditLockEndpoint(lifecycle, endpoint, sinks) {
|
|
|
46
116
|
/** @type {Set<{close: () => Promise<unknown>}>} */
|
|
47
117
|
const channels = new Set()
|
|
48
118
|
let closed = false
|
|
119
|
+
const arbitration = createChannelBindingArbitration()
|
|
49
120
|
const server = createServer(socket => {
|
|
50
121
|
if (closed) { socket.destroy(); return }
|
|
51
122
|
/** @type {ReturnType<typeof createEditLockPeer> | undefined} */
|
|
52
123
|
let inner
|
|
124
|
+
/** @type {string | undefined} */
|
|
125
|
+
let openedSessionId
|
|
53
126
|
const router = {
|
|
54
127
|
/** @param {any} message */
|
|
55
128
|
async receive(message) {
|
|
56
129
|
if (inner) return inner.receive(message)
|
|
57
130
|
const sessionId = message?.request?.sessionId
|
|
58
131
|
if (message?.kind !== 'open' || typeof sessionId !== 'string' || !sessionId || sessionId.length > 256) throw new Error('channel must open a session first')
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
132
|
+
openedSessionId = sessionId
|
|
133
|
+
const opened = await arbitration.open(sessionId, socket, async () => {
|
|
134
|
+
const agent = Object.freeze({ id: sessionId, remote: true })
|
|
135
|
+
sinks.set(agent, event => transport.notify(event))
|
|
136
|
+
inner = createEditLockPeer(lifecycle, agent)
|
|
137
|
+
const state = await lifecycle.start(agent)
|
|
138
|
+
return { peer: inner, state }
|
|
139
|
+
})
|
|
140
|
+
return { state: opened.state }
|
|
63
141
|
},
|
|
64
142
|
disconnect() { return inner ? inner.disconnect() : Promise.resolve() },
|
|
65
143
|
}
|
|
66
144
|
const transport = serveEditLockPeer(socket, router)
|
|
67
145
|
channels.add(transport)
|
|
68
|
-
socket.once('close', () =>
|
|
146
|
+
socket.once('close', () => {
|
|
147
|
+
channels.delete(transport)
|
|
148
|
+
if (openedSessionId !== undefined) arbitration.release(openedSessionId, socket)
|
|
149
|
+
})
|
|
69
150
|
})
|
|
70
151
|
await new Promise((resolve, reject) => {
|
|
71
152
|
server.once('error', reject)
|
|
@@ -99,14 +180,25 @@ export function createRemoteEditLockDomain(endpoint, hooks) {
|
|
|
99
180
|
const all = new Set()
|
|
100
181
|
const never = new AbortController().signal
|
|
101
182
|
let closed = false
|
|
102
|
-
/**
|
|
103
|
-
*
|
|
183
|
+
/** Failure classes are split by WHERE the open failed, never by guessing at
|
|
184
|
+
* message text (design D3): a transport that never came up — or an open
|
|
185
|
+
* request that never got an answer — is an unreachable publisher and carries
|
|
186
|
+
* the reservation hint; an open the publisher answered and refused is a live
|
|
187
|
+
* publisher rejecting the binding, surfaced with its own guidance after the
|
|
188
|
+
* duplicate-binding retry budget runs out. Only that answered refusal is
|
|
189
|
+
* retried: the publisher may not yet have processed (or even seen) the
|
|
190
|
+
* earlier channel's death. Never a call.
|
|
104
191
|
* @param {any} agent @returns {Promise<Channel>} */
|
|
105
192
|
async function channelFor(agent) {
|
|
106
193
|
for (let attempt = 0; ; attempt++) {
|
|
107
194
|
try { return await openChannel(agent) } catch (error) {
|
|
108
|
-
if (
|
|
109
|
-
|
|
195
|
+
if (/** @type {any} */ (error)?.answeredRefusal !== true) throw error
|
|
196
|
+
if (/session already bound/.test(String(/** @type {any} */ (error)?.message)) && attempt < 20) {
|
|
197
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
const reason = String(/** @type {any} */ (error)?.message ?? error)
|
|
201
|
+
throw new Error(`edit lock duplicate session binding: session ${agent.id} is already active in the DeepSeek Harness window that publishes this project. Use the session in that window, close it there, or quit that Harness process. Do NOT remove the publisher reservation (.weir/.edit-lock.publisher-reservation) while that process is alive — the reservation belongs to it, and removing it would split the edit authority. (publisher answered: ${reason})`)
|
|
110
202
|
}
|
|
111
203
|
}
|
|
112
204
|
}
|
|
@@ -124,17 +216,35 @@ export function createRemoteEditLockDomain(endpoint, hooks) {
|
|
|
124
216
|
})
|
|
125
217
|
channels.set(agent, channel)
|
|
126
218
|
all.add(channel)
|
|
219
|
+
/** The publisher genuinely did not answer. @param {unknown} cause */
|
|
220
|
+
const unreachable = cause => new Error(`edit lock publisher unreachable at ${endpoint}: ${/** @type {any} */ (cause)?.message ?? cause}${hooks.unreachableHint ? `. ${hooks.unreachableHint}` : ''}`)
|
|
127
221
|
channel.ready = (async () => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
222
|
+
try {
|
|
223
|
+
await new Promise((resolve, reject) => {
|
|
224
|
+
socket.once('connect', resolve)
|
|
225
|
+
socket.once('error', reject)
|
|
226
|
+
})
|
|
227
|
+
} catch (error) {
|
|
228
|
+
// The transport never came up: the publisher process is not answering.
|
|
229
|
+
throw unreachable(error)
|
|
230
|
+
}
|
|
231
|
+
let opened
|
|
232
|
+
try {
|
|
233
|
+
opened = await channel.client.request('open', 'open', { sessionId: agent.id }, never)
|
|
234
|
+
} catch (error) {
|
|
235
|
+
const message = String(/** @type {any} */ (error)?.message ?? error)
|
|
236
|
+
// No answer ever arrived — the channel died mid-open or the request
|
|
237
|
+
// was never dispatched: the same failure class as a connect error.
|
|
238
|
+
if (message.startsWith('edit publication outcome UNKNOWN:') || message.startsWith('edit peer unavailable')) throw unreachable(error)
|
|
239
|
+
// The publisher answered and refused the open. channelFor owns the
|
|
240
|
+
// retry budget and the final message for answered refusals.
|
|
241
|
+
throw Object.assign(error instanceof Error ? error : new Error(message), { answeredRefusal: true })
|
|
242
|
+
}
|
|
133
243
|
if (!channel.closed) channel.state = opened.state
|
|
134
244
|
})()
|
|
135
245
|
try { await channel.ready } catch (error) {
|
|
136
246
|
channel.client.close()
|
|
137
|
-
throw
|
|
247
|
+
throw error
|
|
138
248
|
}
|
|
139
249
|
return channel
|
|
140
250
|
}
|
package/src/worktree/pkgmgr.js
CHANGED
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
// bundled npm only exists when the bundled Node directory actually contains
|
|
8
8
|
// an `npm`; yarn and bun have no bundled offer. The returned command
|
|
9
9
|
// prepends the resolved Node/bin directory to PATH so install lifecycle
|
|
10
|
-
// scripts can spawn node, and
|
|
11
|
-
// straight through.
|
|
10
|
+
// scripts can spawn node, and hands the process over to the manager so its
|
|
11
|
+
// exit code passes straight through. The command text matches the host
|
|
12
|
+
// shell: POSIX sh (`export PATH=...; exec ...`) everywhere except win32,
|
|
13
|
+
// where the host shell is PowerShell (`$env:PATH = ...; & ...; exit
|
|
14
|
+
// $LASTEXITCODE`) — `export`/`exec` are not cmdlets and fail there.
|
|
15
|
+
// User-CONFIGURED setup strings never enter this module.
|
|
12
16
|
// Pure module (no ctx, no node: imports) — every fact arrives through seams.
|
|
13
17
|
/** Install arguments per package manager (frozen-lockfile semantics kept). */
|
|
14
18
|
export const MANAGER_INSTALL = Object.freeze({
|
|
@@ -42,10 +46,27 @@ export function quoteSh(word) {
|
|
|
42
46
|
return `'${text.replace(/'/g, `'\\''`)}'`
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Single-quote a word for PowerShell: a single-quoted PowerShell string has
|
|
51
|
+
* no escape semantics — the only special character inside is ' itself,
|
|
52
|
+
* doubled. Every word is wrapped (there is no safe-word pass-through).
|
|
53
|
+
* @param {string} word
|
|
54
|
+
*/
|
|
55
|
+
export function quotePs(word) {
|
|
56
|
+
return `'${String(word).replace(/'/g, `''`)}'`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* dirname of a '/'- or '\'-separated path. POSIX edge semantics are kept
|
|
61
|
+
* (root is '/', no separator is '.'); a win32 drive root keeps its
|
|
62
|
+
* separator: dirOf('C:\\x') is 'C:\\', never 'C:'.
|
|
63
|
+
* @param {string} path
|
|
64
|
+
*/
|
|
46
65
|
function dirOf(path) {
|
|
47
|
-
const at = path.lastIndexOf('/')
|
|
48
|
-
|
|
66
|
+
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
|
67
|
+
if (at <= 0) return at === 0 ? '/' : '.'
|
|
68
|
+
if (at === 2 && /^[A-Za-z]:/.test(path)) return path.slice(0, 3)
|
|
69
|
+
return path.slice(0, at)
|
|
49
70
|
}
|
|
50
71
|
|
|
51
72
|
/**
|
|
@@ -55,6 +76,7 @@ function dirOf(path) {
|
|
|
55
76
|
* @property {(path: string) => boolean} isFile
|
|
56
77
|
* @property {string | undefined} dshHome - the deployment's DSH home
|
|
57
78
|
* @property {string} [home] - user home, used as `<home>/.dsh` when dshHome is unset
|
|
79
|
+
* @property {string} [platform] - host platform ('win32' switches the command form, dirname separators, and the bundled node file name); defaults to process.platform
|
|
58
80
|
*/
|
|
59
81
|
|
|
60
82
|
/**
|
|
@@ -66,6 +88,10 @@ function dirOf(path) {
|
|
|
66
88
|
export function resolveDerivedSetup(manager, seams) {
|
|
67
89
|
const args = MANAGER_INSTALL[manager]
|
|
68
90
|
if (!args) return { ok: false, manager }
|
|
91
|
+
// Defaulting to the HOST platform is the fail-safe choice: emitting POSIX
|
|
92
|
+
// text on a Windows host is exactly the failure this seam exists to
|
|
93
|
+
// prevent. Tests pin the platform explicitly.
|
|
94
|
+
const platform = seams.platform ?? process.platform
|
|
69
95
|
const system = seams.foundOnPath(manager)
|
|
70
96
|
if (system) {
|
|
71
97
|
const node = seams.foundOnPath('node')
|
|
@@ -77,7 +103,7 @@ export function resolveDerivedSetup(manager, seams) {
|
|
|
77
103
|
// Node's directory first: the manager's shim and every install
|
|
78
104
|
// lifecycle script resolve node through PATH, and the manager's own
|
|
79
105
|
// directory (e.g. ~/.npm-global/bin) need not contain one.
|
|
80
|
-
command: wrap([dirOf(node), dirOf(system)], [system, ...args]),
|
|
106
|
+
command: wrap([dirOf(node), dirOf(system)], [system, ...args], platform),
|
|
81
107
|
}
|
|
82
108
|
}
|
|
83
109
|
// A manager whose interpreter is not on PATH cannot run either — fall
|
|
@@ -86,6 +112,8 @@ export function resolveDerivedSetup(manager, seams) {
|
|
|
86
112
|
const offer = BUNDLED_OFFER[/** @type {'pnpm' | 'npm'} */ (manager)]
|
|
87
113
|
const home = seams.dshHome ?? (seams.home ? `${seams.home}/.dsh` : undefined)
|
|
88
114
|
if (offer && home) {
|
|
115
|
+
// The bundled Node executable carries the platform's file name.
|
|
116
|
+
const nodeRel = platform === 'win32' ? `${BUNDLED_REL.node}.exe` : BUNDLED_REL.node
|
|
89
117
|
let runtimes = []
|
|
90
118
|
try {
|
|
91
119
|
runtimes = seams.listDirs(`${home}/dsh-runtimes`)
|
|
@@ -94,7 +122,7 @@ export function resolveDerivedSetup(manager, seams) {
|
|
|
94
122
|
}
|
|
95
123
|
for (const runtime of [...runtimes].sort()) {
|
|
96
124
|
const root = `${home}/dsh-runtimes/${runtime}`
|
|
97
|
-
const node = `${root}/${
|
|
125
|
+
const node = `${root}/${nodeRel}`
|
|
98
126
|
const tool = `${root}/${offer}`
|
|
99
127
|
if (!seams.isFile(node) || !seams.isFile(tool)) continue
|
|
100
128
|
const argv = manager === 'pnpm' ? [node, tool, ...args] : [tool, ...args]
|
|
@@ -102,7 +130,7 @@ export function resolveDerivedSetup(manager, seams) {
|
|
|
102
130
|
ok: true,
|
|
103
131
|
source: 'bundled',
|
|
104
132
|
display: argv.map((part) => (part === node ? 'node' : part)).join(' '),
|
|
105
|
-
command: wrap([`${root}/dependencies/node/bin`], argv),
|
|
133
|
+
command: wrap([`${root}/dependencies/node/bin`], argv, platform),
|
|
106
134
|
}
|
|
107
135
|
}
|
|
108
136
|
}
|
|
@@ -118,8 +146,19 @@ export function setupMissingReason(manager) {
|
|
|
118
146
|
return `setup needs ${manager}, but it was found neither on PATH nor in the DSH bundled runtime. Install ${manager} yourself, set "setup" in .weir/worktrees/.config.json, or skip with /worktree setup <lane> --skip`
|
|
119
147
|
}
|
|
120
148
|
|
|
121
|
-
/**
|
|
122
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Build the derived setup command text for the host shell. POSIX keeps the
|
|
151
|
+
* sh form (`export PATH=...:"$PATH"; exec ...`) byte for byte. win32 targets
|
|
152
|
+
* PowerShell: the PATH prefix is one single-quoted ';'-joined string
|
|
153
|
+
* prepended to $env:PATH, the argv runs through the `&` call operator with
|
|
154
|
+
* every element single-quoted, and `exit $LASTEXITCODE` passes the manager's
|
|
155
|
+
* exit code through (the role `exec` plays on POSIX).
|
|
156
|
+
* @param {string[]} binDirs @param {string[]} argv @param {string} platform
|
|
157
|
+
*/
|
|
158
|
+
function wrap(binDirs, argv, platform) {
|
|
159
|
+
if (platform === 'win32') {
|
|
160
|
+
return `$env:PATH = ${quotePs(`${binDirs.join(';')};`)} + $env:PATH; & ${argv.map(quotePs).join(' ')}; exit $LASTEXITCODE`
|
|
161
|
+
}
|
|
123
162
|
const path = binDirs.map(quoteSh).join(':')
|
|
124
163
|
return `export PATH=${path}:"$PATH"; exec ${argv.map(quoteSh).join(' ')}`
|
|
125
164
|
}
|
package/src/worktree/runner.js
CHANGED
|
@@ -102,10 +102,10 @@ export function createGitRunner(subprocess) {
|
|
|
102
102
|
* manager first (the LSP module's extended resolver — S21), then the DSH
|
|
103
103
|
* bundled runtime under the deployment's DSH home. Pure decision in
|
|
104
104
|
* pkgmgr.js; this is only the seam wiring.
|
|
105
|
-
* @param {{ subprocess: any, env?: Record<string, string | undefined>, home?: string, findExecutable?: (name: string) => Promise<string | undefined
|
|
105
|
+
* @param {{ subprocess: any, env?: Record<string, string | undefined>, home?: string, findExecutable?: (name: string) => Promise<string | undefined>, platform?: string }} options - `findExecutable` exists so tests can pin the lookup without fighting the host's real PATH scan; `platform` defaults to process.platform and exists so tests can pin it
|
|
106
106
|
* @returns {(manager: string) => Promise<ReturnType<typeof resolveDerivedSetup>>}
|
|
107
107
|
*/
|
|
108
|
-
export function createSetupResolver({ subprocess, env = process.env, home = homedir(), findExecutable }) {
|
|
108
|
+
export function createSetupResolver({ subprocess, env = process.env, home = homedir(), findExecutable, platform = process.platform }) {
|
|
109
109
|
const find = findExecutable ?? ((name) => resolveExecutable(subprocess, name).catch(() => undefined))
|
|
110
110
|
return async (manager) => {
|
|
111
111
|
// Manager and node are resolved INDEPENDENTLY: the core asks the seam
|
|
@@ -130,6 +130,7 @@ export function createSetupResolver({ subprocess, env = process.env, home = home
|
|
|
130
130
|
},
|
|
131
131
|
dshHome: env.DSH_HOME,
|
|
132
132
|
home,
|
|
133
|
+
platform,
|
|
133
134
|
})
|
|
134
135
|
}
|
|
135
136
|
}
|