weir-harness 1.1.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -213,8 +213,64 @@ export function createBlackboardStorage({ home, hook = async (_op, _path) => {},
213
213
  return result.publish()
214
214
  } finally { await release() }
215
215
  }
216
+ // One-time fork seeding: a forked Session is its own board root, but its
217
+ // board starts as a snapshot of the fork source's durable root board. Both
218
+ // directories stay under the writer lock in deterministic path order, and
219
+ // the fork is only ever seeded while it has no sidecar of its own — a
220
+ // re-seed can never overwrite or merge an initialized board.
221
+ async function seedFork(id, parentId, cwd) {
222
+ sessionId(id)
223
+ let hopId = sessionId(parentId)
224
+ // The source's durable root is resolved on disk: a delegated source has
225
+ // no sidecar of its own, so ascend its parent chain to the root board it
226
+ // actually used.
227
+ const seen = new Set()
228
+ let source
229
+ for (let hop = 0; hop < 64; hop++) {
230
+ if (seen.has(hopId)) throw new Error('blackboard: cyclic Session parent chain')
231
+ seen.add(hopId)
232
+ const located = await locateSession(home, hopId)
233
+ const depth = located.header.delegationDepth ?? 0
234
+ if (!Number.isSafeInteger(depth) || depth < 0) throw new Error('blackboard: invalid Session delegation depth')
235
+ if (depth === 0) { source = located; break }
236
+ hopId = sessionId(located.header.parentSession)
237
+ }
238
+ if (!source) throw new Error('blackboard: Session ancestry exceeds safety limit')
239
+ const child = await locateSession(home, id, cwd)
240
+ // Two directories need two writer locks; taking them in deterministic
241
+ // path-string order means single-lock users (transact) can never
242
+ // deadlock with this pair. Distinct Session ids never share a directory.
243
+ const [first, second] = source.dir < child.dir ? [source.dir, child.dir] : [child.dir, source.dir]
244
+ const releaseFirst = await lock(first)
245
+ let releaseSecond
246
+ try { releaseSecond = await lock(second) } catch (error) { await releaseFirst(); throw error }
247
+ try {
248
+ const records = []
249
+ for (const name of (await fs.readdir(source.dir)).filter(name => SIDE.test(name))) {
250
+ records.push(decodeRecord(await safeRead(join(source.dir, name)), source.id))
251
+ }
252
+ if (!records.length) return false
253
+ if (records.some(record => JSON.stringify(record) !== JSON.stringify(records[0]))) throw new Error('blackboard: conflicting sidecars; originals preserved')
254
+ // Idempotence: any existing fork sidecar means the board is already
255
+ // initialized — never overwrite or merge.
256
+ if ((await fs.readdir(child.dir)).some(name => SIDE.test(name))) return false
257
+ // Volatile state (tokens/subscriptions/timers) is never persisted, so
258
+ // the snapshot carries durable entry fields only; the fork starts at
259
+ // revision 0 under its own root identity.
260
+ const seeded = { schemaVersion: 1, rootSessionId: id, revision: 0, entries: structuredClone(records[0].entries) }
261
+ const encoded = JSON.stringify(decodeRecord(JSON.stringify(seeded), id)) + '\n'
262
+ await publish(child.file, encoded)
263
+ return true
264
+ } finally {
265
+ let failure
266
+ try { await releaseSecond() } catch (error) { failure ??= error }
267
+ try { await releaseFirst() } catch (error) { failure ??= error }
268
+ if (failure) throw failure
269
+ }
270
+ }
216
271
  return {
217
272
  locate: (id, cwd) => locateSession(home, id, cwd),
273
+ seedFork,
218
274
  async releaseWriter(id, cwd) {
219
275
  let where
220
276
  try { where = await locateSession(home, id, cwd) } catch { return }
@@ -2,6 +2,7 @@
2
2
  * @param {number | undefined} pid @param {string} attestation */
3
3
  export function bindingRefusalReason(pid, attestation) {
4
4
  const holder = attestation === 'serving' ? 'serving'
5
+ : attestation === 'serving-idle' ? 'serving-idle'
5
6
  : pid === process.pid ? 'same-process-unattestable'
6
7
  : pid !== undefined ? 'cross-process-pid' : 'unknown'
7
8
  return `session already bound: holder=${holder}${pid !== undefined ? `; pid=${pid}` : ''}`
@@ -10,12 +11,20 @@ export function bindingRefusalReason(pid, attestation) {
10
11
  /** Render only publisher-classified facts. Unrecognized/legacy reasons are unknown.
11
12
  * @param {string} sessionId @param {string} reason */
12
13
  export function bindingGuidance(sessionId, reason) {
13
- const facts = /^session already bound: holder=(serving|same-process-unattestable|cross-process-pid|unknown)(?:; pid=([1-9]\d*))?$/.exec(reason)
14
+ const facts = /^session already bound: holder=(serving-idle|serving|same-process-unattestable|cross-process-pid|unknown)(?:; pid=([1-9]\d*))?$/.exec(reason)
14
15
  const classification = facts?.[1]
15
16
  const pid = facts?.[2]
16
17
  let holder
17
- if (classification === 'serving') {
18
- holder = `session ${sessionId} 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.`
18
+ if (classification === 'serving-idle') {
19
+ // Transferable (change edit-lock-binding-transfer): an idle same-process
20
+ // holder is taken over by the next driving ingress — calm copy, no pid,
21
+ // no window hunt, and NEVER the publisher-reservation warning.
22
+ holder = `session ${sessionId} is held by an inactive attachment of this same session. Editing attaches here automatically when you next send a message; /edit-lock retry also takes it over now. Retry does not Continue editing.`
23
+ } else if (classification === 'serving') {
24
+ // The holder is provably live and not transferable (mid-turn or a live
25
+ // channel proxy): name what is happening and when to retry. No window
26
+ // hunt (single-instance hosts exist) and no process management.
27
+ holder = `session ${sessionId} is being edited in another attachment right now. Wait for that turn to finish, then use /edit-lock retry in this session; retry does not Continue editing.`
19
28
  } else if (classification === 'same-process-unattestable' && pid !== undefined) {
20
29
  holder = `session ${sessionId} has a binding in this same process (pid ${pid}) that cannot be safely reclaimed. A stale runtime incarnation may still hold its channel. Wait for the original holder to disconnect or for confirmed process shutdown.`
21
30
  } else if (classification === 'cross-process-pid' && pid !== undefined) {
@@ -23,6 +32,7 @@ export function bindingGuidance(sessionId, reason) {
23
32
  } else {
24
33
  holder = `session ${sessionId} has a binding whose holder cannot be attested. Wait for the original holder to disconnect or for confirmed process shutdown.`
25
34
  }
35
+ if (classification === 'serving-idle' || classification === 'serving') return holder
26
36
  return `${holder} Then use /edit-lock retry in the original session when available; retry does not Continue editing. Do NOT remove the publisher reservation (.weir/.edit-lock.publisher-reservation) while its process is alive — removing it would split the edit authority.`
27
37
  }
28
38
 
@@ -32,7 +42,8 @@ export function bindingGuidance(sessionId, reason) {
32
42
  export function bindingFailure(pid, attestation, revocationFailed = false) {
33
43
  return { code: 'binding-duplicate', retryable: true,
34
44
  holderKind: revocationFailed ? 'revocation-failed' : attestation === 'serving' ? 'serving'
35
- : pid !== undefined && pid !== process.pid ? 'cross-process' : 'unknown',
45
+ : attestation === 'serving-idle' ? 'transferable'
46
+ : pid !== undefined && pid !== process.pid ? 'cross-process' : 'unknown',
36
47
  holderPid: pid ?? null }
37
48
  }
38
49
  /** @param {number | undefined} pid @param {string} attestation @param {boolean} [revocationFailed] */
@@ -15,6 +15,14 @@ export function localDomain(lifecycle, endpoint) {
15
15
  lifecycle.assertStartable(agent)
16
16
  return endpoint?.startLocal ? endpoint.startLocal(agent, true) : lifecycle.start(agent)
17
17
  },
18
+ /** Trusted binding transfer (change edit-lock-binding-transfer): the
19
+ * explicit driving-ingress counterpart of retryStart — the holder-side
20
+ * admission (local, registry-named, provably idle holder) is re-adjudicated
21
+ * at execution and refuses exactly like start() otherwise. @param {object} agent */
22
+ transferStart: async agent => {
23
+ lifecycle.assertStartable(agent)
24
+ return endpoint?.startLocal ? endpoint.startLocal(agent, true, true) : lifecycle.transferStart(agent)
25
+ },
18
26
  /** Synchronous admission fence for an aborted turn. @param {object} agent */
19
27
  latch: agent => { lifecycle.stop(agent).catch(() => {}) },
20
28
  /** @param {object} agent */
@@ -86,6 +94,10 @@ export function remoteDomain(remote) {
86
94
  start: async agent => (await remote.channelFor(agent)).state,
87
95
  /** @param {object} agent */
88
96
  retryStart: async agent => (await remote.channelFor(agent, true)).state,
97
+ /** The wire retry flag carries the transfer admission to the publisher
98
+ * (its lifecycle re-adjudicates holder idleness); the client adds nothing. */
99
+ /** @param {object} agent */
100
+ transferStart: async agent => (await remote.channelFor(agent, true)).state,
89
101
  /** @param {object} agent */
90
102
  forget: async agent => { try { await remote.call(agent, 'stop') } finally { remote.drop(agent) } },
91
103
  /** Closing the channel makes the publisher revoke on EOF. @param {object} agent */
@@ -97,6 +97,73 @@ export function createDomainRegistry(open, resolveRoot = managementRootFor) {
97
97
  })
98
98
  }
99
99
 
100
+ /** @type {WeakMap<object, Map<string, {registry: any, settled: WeakMap<object, any>, recoveryNotices: Map<string, string>, mounts: number}>>} */
101
+ const sharedRegistriesByHost = new WeakMap()
102
+
103
+ /** Process-shared domain registry container (change
104
+ * edit-lock-shared-domain-mounts, design D1): several preset mounts of this
105
+ * bundle coexist for the whole process, and every mount of one management
106
+ * root must resolve the SAME domain — one runtime, one publisher reservation,
107
+ * one lifecycle and one endpoint — instead of the second mount degrading to a
108
+ * same-process remote client of the first.
109
+ *
110
+ * The container is keyed by host (`ctx.root ?? ctx`, the same key expression
111
+ * the write-scope store uses — proven to be shared across preset mounts) plus
112
+ * a caller-supplied configuration discriminator: mounts whose effective
113
+ * domain configuration differs (a fixed root/authority row) NEVER share; they
114
+ * keep today's independent-registry semantics exactly.
115
+ *
116
+ * Reference-counted by mount: each apply takes one count, each disposer
117
+ * releases exactly one (idempotently); a factory that throws enters no count.
118
+ * Only the LAST release closes every domain through the established drain
119
+ * discipline (`registry.all() → domain.close()`), revoking sessions and
120
+ * releasing reservations; the entry is then dropped, so a later generation
121
+ * reopens fresh. The per-entry `settled` store and the per-root
122
+ * automatic-recovery notice queue share the entry's lifetime: they outlive
123
+ * any single mount (a preset switch lets a sibling mount's listeners read
124
+ * the binding the first mount settled, and the mount that hears the first
125
+ * post-recovery turn drains the summary), and they die with the last
126
+ * release so a closed domain is never mistaken for a live binding.
127
+ *
128
+ * @template T
129
+ * @param {object} host
130
+ * @param {string} discriminator
131
+ * @param {() => ReturnType<typeof createDomainRegistry<T>>} makeRegistry
132
+ * @returns {{registry: ReturnType<typeof createDomainRegistry<T>>, settled: WeakMap<object, T>, recoveryNotices: Map<string, string>, release: () => Promise<void>}} */
133
+ export function sharedDomainRegistryFor(host, discriminator, makeRegistry) {
134
+ let entries = sharedRegistriesByHost.get(host)
135
+ if (!entries?.has(discriminator)) {
136
+ // A throwing factory mutates nothing and enters no count.
137
+ const created = makeRegistry()
138
+ if (!entries) {
139
+ entries = new Map()
140
+ sharedRegistriesByHost.set(host, entries)
141
+ }
142
+ entries.set(discriminator, { registry: created, settled: new WeakMap(), recoveryNotices: new Map(), mounts: 0 })
143
+ }
144
+ const entry = /** @type {{registry: any, settled: WeakMap<object, any>, recoveryNotices: Map<string, string>, mounts: number}} */ (entries.get(discriminator))
145
+ entry.mounts += 1
146
+ let released = false
147
+ return Object.freeze({
148
+ registry: entry.registry,
149
+ settled: entry.settled,
150
+ /** One queued automatic-recovery summary per root, shared so the mount
151
+ * that hears the first post-recovery turn drains it (design D3). */
152
+ recoveryNotices: entry.recoveryNotices,
153
+ /** Idempotent: only the first call counts; only the last call closes. */
154
+ async release() {
155
+ if (released) return
156
+ released = true
157
+ entry.mounts -= 1
158
+ if (entry.mounts > 0 || entries.get(discriminator) !== entry) return
159
+ entries.delete(discriminator)
160
+ const results = await Promise.allSettled(entry.registry.all().map(async domain => (await domain).close()))
161
+ const failure = results.find(result => result.status === 'rejected')
162
+ if (failure?.status === 'rejected') throw failure.reason
163
+ },
164
+ })
165
+ }
166
+
100
167
  /** @param {string} path */
101
168
  export function isDirectory(path) {
102
169
  try { return lstatSync(path).isDirectory() } catch { return false }