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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weir-harness",
3
- "version": "1.1.3",
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",
@@ -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
- const agent = Object.freeze({ id: sessionId, remote: true })
60
- sinks.set(agent, event => transport.notify(event))
61
- inner = createEditLockPeer(lifecycle, agent)
62
- return { state: await lifecycle.start(agent) }
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', () => channels.delete(transport))
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
- /** The publisher may not yet have processed an earlier channel's EOF; only
103
- * channel establishment is retried, never a call.
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 (attempt >= 20 || !/session already bound/.test(String(/** @type {any} */ (error)?.message))) throw error
109
- await new Promise(resolve => setTimeout(resolve, 50))
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
- await new Promise((resolve, reject) => {
129
- socket.once('connect', resolve)
130
- socket.once('error', reject)
131
- })
132
- const opened = await channel.client.request('open', 'open', { sessionId: agent.id }, never)
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 new Error(`edit lock publisher unreachable at ${endpoint}: ${/** @type {any} */ (error)?.message ?? error}${hooks.unreachableHint ? `. ${hooks.unreachableHint}` : ''}`)
247
+ throw error
138
248
  }
139
249
  return channel
140
250
  }
@@ -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 `exec`s the manager so its exit code passes
11
- // straight through. User-CONFIGURED setup strings never enter this module.
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
- /** POSIX dirname of a '/'-separated path. @param {string} path */
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
- return at > 0 ? path.slice(0, at) : at === 0 ? '/' : '.'
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}/${BUNDLED_REL.node}`
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
- /** @param {string[]} binDirs @param {string[]} argv */
122
- function wrap(binDirs, argv) {
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
  }
@@ -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> }} options - `findExecutable` exists so tests can pin the lookup without fighting the host's real PATH scan
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
  }