weir-harness 1.2.0 → 1.2.1
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/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 +1 -1
- 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-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/delegate/group-coordinator.js +1 -17
- package/src/delegate/index.js +4 -2
- package/src/delegate/rehydrate.js +2 -1
- package/src/delegate/robash-guard-core.js +2 -2
- package/src/delegate/supervision-mount.js +5 -4
- package/src/delegate/tool.js +26 -3
- 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} +12 -4
- 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 +5 -0
- 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
|
@@ -8,6 +8,7 @@ declare module 'node:fs' {
|
|
|
8
8
|
export function renameSync(from: string, to: string): void;
|
|
9
9
|
export function unlinkSync(path: string): void;
|
|
10
10
|
export function fsyncSync(fd: number): void;
|
|
11
|
+
export function linkSync(existingPath: string, newPath: string): void;
|
|
11
12
|
}
|
|
12
13
|
declare module 'node:fs/promises' {
|
|
13
14
|
export function link(existingPath: string, newPath: string): Promise<void>;
|
|
@@ -23,10 +24,14 @@ declare module 'node:child_process' {
|
|
|
23
24
|
}
|
|
24
25
|
declare module 'node:os' {
|
|
25
26
|
export function hostname(): string;
|
|
27
|
+
export function homedir(): string;
|
|
26
28
|
}
|
|
27
29
|
declare var process: {
|
|
28
30
|
pid: number;
|
|
29
31
|
platform: string;
|
|
32
|
+
execPath: string;
|
|
33
|
+
cwd(): string;
|
|
34
|
+
env: Record<string, string | undefined>;
|
|
30
35
|
kill(pid: number, signal?: number): void;
|
|
31
36
|
};
|
|
32
37
|
|
|
@@ -106,3 +106,55 @@ export function instrumentFs(inner, hook) {
|
|
|
106
106
|
}
|
|
107
107
|
return /** @type {StoreFs} */ (wrapped)
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
// ---- Synchronous counterpart (capabilities-deepening D4) ---------------------
|
|
111
|
+
// The lifecycle listener may not yield the event loop (G4b), so the store's
|
|
112
|
+
// sync commit/read path needs blocking I/O with the SAME durability
|
|
113
|
+
// discipline as the async Adapter above: full-content write, file fsync,
|
|
114
|
+
// link/rename publication, directory fsync (POSIX-only — the same platform
|
|
115
|
+
// stance as fsyncDir). One seam, so a test can wrap these calls exactly like
|
|
116
|
+
// instrumentFs wraps the async ones.
|
|
117
|
+
|
|
118
|
+
import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @typedef {{
|
|
122
|
+
* readTextSync(path: string): string,
|
|
123
|
+
* writeFullSync(path: string, text: string): void,
|
|
124
|
+
* fsyncFileSync(path: string): void,
|
|
125
|
+
* linkSync(from: string, to: string): void,
|
|
126
|
+
* renameSync(from: string, to: string): void,
|
|
127
|
+
* unlinkSync(path: string): void,
|
|
128
|
+
* mkdirpSync(path: string): void,
|
|
129
|
+
* fsyncDirSync(path: string): void,
|
|
130
|
+
* }} StoreSyncFs
|
|
131
|
+
*/
|
|
132
|
+
|
|
133
|
+
/** @returns {StoreSyncFs} */
|
|
134
|
+
export function createNodeSyncFs() {
|
|
135
|
+
return {
|
|
136
|
+
readTextSync: path => readFileSync(path, 'utf8'),
|
|
137
|
+
writeFullSync(path, text) { writeFileSync(path, text, { mode: 0o600 }) },
|
|
138
|
+
fsyncFileSync(path) {
|
|
139
|
+
const fd = openSync(path, 'r+')
|
|
140
|
+
try {
|
|
141
|
+
fsyncSync(fd)
|
|
142
|
+
} finally {
|
|
143
|
+
closeSync(fd)
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
linkSync: (from, to) => linkSync(from, to),
|
|
147
|
+
renameSync: (from, to) => renameSync(from, to),
|
|
148
|
+
unlinkSync: path => unlinkSync(path),
|
|
149
|
+
mkdirpSync(path) { mkdirSync(path, { recursive: true, mode: 0o700 }) },
|
|
150
|
+
fsyncDirSync(path) {
|
|
151
|
+
if (!DIR_SYNC_SUPPORTED) return
|
|
152
|
+
const fd = openSync(path, 'r')
|
|
153
|
+
try {
|
|
154
|
+
fsyncSync(fd)
|
|
155
|
+
} finally {
|
|
156
|
+
closeSync(fd)
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -264,3 +264,89 @@ export function createLockProtocol(options) {
|
|
|
264
264
|
|
|
265
265
|
return { acquire, release, cleanup, inspect, clear }
|
|
266
266
|
}
|
|
267
|
+
|
|
268
|
+
// ---- Synchronous nominal-lease variant (capabilities-deepening D4) -----------
|
|
269
|
+
// For callers that may not yield the event loop (the agent/created lifecycle
|
|
270
|
+
// listener): the SAME publication convention as the async protocol above —
|
|
271
|
+
// private candidate + fsync + link (EEXIST = contention), the same OwnerDoc
|
|
272
|
+
// schema, remove-only-if-ours release — but with NO backoff and NO
|
|
273
|
+
// reclamation: contention fails closed, and a crashed holder's leftover lock
|
|
274
|
+
// becomes reclaimable to the async protocol after the nominal lease expires
|
|
275
|
+
// ('sync-capture' marks an owner whose start identity was never probed, so a
|
|
276
|
+
// same-process leftover reads as dead once the lease lapses).
|
|
277
|
+
|
|
278
|
+
import { hostname } from 'node:os'
|
|
279
|
+
import { createNodeSyncFs } from './fs-adapter.js'
|
|
280
|
+
|
|
281
|
+
/** Nominal lease of a synchronously acquired lock (acquired, used, released in one segment). */
|
|
282
|
+
export const SYNC_LOCK_LEASE_MS = 10_000
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* @param {{ fs?: import('./fs-adapter.js').StoreSyncFs, now?: () => number, token?: () => string }} [options]
|
|
286
|
+
*/
|
|
287
|
+
export function createSyncLock(options = {}) {
|
|
288
|
+
const fs = options.fs ?? createNodeSyncFs()
|
|
289
|
+
const now = options.now ?? Date.now
|
|
290
|
+
const token = options.token ?? (() => randomBytes(12).toString('hex'))
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Acquire `<dir>/<name>.lock` synchronously or fail closed. Never writes
|
|
294
|
+
* the unit record. Contention (EEXIST) is a 'locked' refusal, not a wait.
|
|
295
|
+
* @param {string} dir @param {string} name
|
|
296
|
+
* @returns {{ ok: true, token: string } | { ok: false, reason: 'locked' }}
|
|
297
|
+
*/
|
|
298
|
+
function acquire(dir, name) {
|
|
299
|
+
const ownerToken = token()
|
|
300
|
+
const lockFile = join(dir, `${name}.lock`)
|
|
301
|
+
const candidate = `${lockFile}.${ownerToken}.cand`
|
|
302
|
+
const at = now()
|
|
303
|
+
/** @type {OwnerDoc} */
|
|
304
|
+
const doc = {
|
|
305
|
+
schemaVersion: LOCK_SCHEMA_VERSION,
|
|
306
|
+
ownerToken,
|
|
307
|
+
pid: process.pid,
|
|
308
|
+
host: hostname(),
|
|
309
|
+
startIdentity: { osStart: null, bootNonce: 'sync-capture' },
|
|
310
|
+
acquiredAt: at,
|
|
311
|
+
leaseUntil: at + SYNC_LOCK_LEASE_MS,
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
fs.writeFullSync(candidate, JSON.stringify(doc))
|
|
315
|
+
fs.fsyncFileSync(candidate)
|
|
316
|
+
fs.linkSync(candidate, lockFile)
|
|
317
|
+
} catch (error) {
|
|
318
|
+
try { fs.unlinkSync(candidate) } catch { /* the candidate may never have landed */ }
|
|
319
|
+
// EEXIST = the lock is held; ENOENT = the holder's orphan sweep removed
|
|
320
|
+
// our candidate mid-race (the async publish() convention) — both mean
|
|
321
|
+
// this round is lost, and the caller fails closed or retries.
|
|
322
|
+
if (hasCode(error, 'EEXIST', 'ENOENT')) return { ok: false, reason: /** @type {const} */ ('locked') }
|
|
323
|
+
throw error
|
|
324
|
+
}
|
|
325
|
+
// Candidate cleanup is best-effort (the async publish() convention): a
|
|
326
|
+
// leftover candidate is swept by the next async acquire's cleanup.
|
|
327
|
+
try { fs.unlinkSync(candidate) } catch { /* best effort */ }
|
|
328
|
+
return { ok: true, token: ownerToken }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Remove `<dir>/<name>.lock` only while it still names our token. @param {string} dir @param {string} name @param {string} ownerToken */
|
|
332
|
+
function release(dir, name, ownerToken) {
|
|
333
|
+
const lockFile = join(dir, `${name}.lock`)
|
|
334
|
+
let text
|
|
335
|
+
try {
|
|
336
|
+
text = fs.readTextSync(lockFile)
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (hasCode(error, 'ENOENT')) return false
|
|
339
|
+
throw error
|
|
340
|
+
}
|
|
341
|
+
const doc = parseOwnerDoc(text)
|
|
342
|
+
if (doc.kind === 'ok' && doc.value.ownerToken !== ownerToken) return false
|
|
343
|
+
try {
|
|
344
|
+
fs.unlinkSync(lockFile)
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (!hasCode(error, 'ENOENT')) throw error
|
|
347
|
+
}
|
|
348
|
+
return true
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { acquire, release }
|
|
352
|
+
}
|
|
@@ -33,6 +33,7 @@ export function resolveStoreRoot(profileContext) {
|
|
|
33
33
|
* @typedef {{ kind: 'selection', sessionId: string }
|
|
34
34
|
* | { kind: 'content', sessionId: string }
|
|
35
35
|
* | { kind: 'inherited', sessionId: string }
|
|
36
|
+
* | { kind: 'notice', sessionId: string }
|
|
36
37
|
* | { kind: 'defaults', workspaceKey: string }
|
|
37
38
|
* | { kind: 'preset', scope: 'global', presetId: string }
|
|
38
39
|
* | { kind: 'preset', scope: 'workspace', workspaceKey: string, presetId: string }
|
|
@@ -55,6 +56,10 @@ export function unitLayout(unit) {
|
|
|
55
56
|
case 'selection':
|
|
56
57
|
case 'content':
|
|
57
58
|
case 'inherited':
|
|
59
|
+
// The pending model-facing notice (durable-pending-notice D1): its own
|
|
60
|
+
// unit, so the accepted selection record's revision — the client's CAS
|
|
61
|
+
// token — is never touched by delivery bookkeeping. Absent = nothing owed.
|
|
62
|
+
case 'notice':
|
|
58
63
|
need(u.sessionId)
|
|
59
64
|
return layout(['sessions', /** @type {string} */ (u.sessionId)], u.kind)
|
|
60
65
|
case 'defaults':
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
// read(unit), commit(unit, expectedRevision, mutate) and publishPointer(scope,
|
|
3
3
|
// next). Locking, stale-lock reclamation, temp cleanup, schema checks and the
|
|
4
4
|
// platform gate stay inside; the filesystem Adapter is the only seam.
|
|
5
|
+
// A committed result also reports the payload its CAS replaced (`committedOver`,
|
|
6
|
+
// read under the same per-unit lock that performed the write): the store is the
|
|
7
|
+
// only component that can prove a write's "before" state without a race, so no
|
|
8
|
+
// caller may re-derive it from an unlocked read.
|
|
5
9
|
import { join } from 'node:path'
|
|
6
|
-
import { createNodeFs } from './fs-adapter.js'
|
|
10
|
+
import { createNodeFs, createNodeSyncFs } from './fs-adapter.js'
|
|
7
11
|
import { createLiveness } from './liveness.js'
|
|
8
|
-
import { createLockProtocol } from './lock.js'
|
|
12
|
+
import { createLockProtocol, createSyncLock } from './lock.js'
|
|
9
13
|
import { validPresetPayload } from './enumerate.js'
|
|
10
14
|
import { isFileSegment, isSegment, resolveStoreRoot, unitLayout } from './paths.js'
|
|
11
15
|
import { decodeRecord, digestOf, encodeRecord } from './record.js'
|
|
@@ -23,7 +27,7 @@ export const SUPPORTED_PLATFORMS = Object.freeze(['darwin', 'win32'])
|
|
|
23
27
|
* @typedef {import('./record.js').Receipt} Receipt
|
|
24
28
|
* @typedef {import('./record.js').RecordRead} RecordRead
|
|
25
29
|
* @typedef {{ requestId?: string, requestDigest?: string }} RequestRef
|
|
26
|
-
* @typedef {{ status: 'committed', revision: number, payload: unknown, receipt: Receipt|null }
|
|
30
|
+
* @typedef {{ status: 'committed', revision: number, payload: unknown, receipt: Receipt|null, committedOver: unknown }
|
|
27
31
|
* | { status: 'duplicate', revision: number, receipt: Receipt }
|
|
28
32
|
* | { status: 'revision-conflict', revision: number }
|
|
29
33
|
* | { status: 'request-conflict', receipt: Receipt }
|
|
@@ -61,6 +65,11 @@ export function openCapabilityStore(options) {
|
|
|
61
65
|
? { supported: false, reason: `platform-unsupported:${platform}` }
|
|
62
66
|
: /** @type {any} */ (located)
|
|
63
67
|
const lock = createLockProtocol({ fs, liveness, leaseMs: options.leaseMs, deadlineMs: options.deadlineMs, now, token: options.token })
|
|
68
|
+
// The synchronous path (capabilities-deepening D4): same root/layout/record
|
|
69
|
+
// schema, the sync fs counterpart and the nominal-lease sync lock — acquired,
|
|
70
|
+
// used and released in one blocking segment, contention fails closed.
|
|
71
|
+
const syncFs = options.syncFs ?? createNodeSyncFs()
|
|
72
|
+
const syncLock = createSyncLock({ fs: syncFs, now, token: options.token })
|
|
64
73
|
/** @type {Map<string, Promise<unknown>>} */
|
|
65
74
|
const queues = new Map()
|
|
66
75
|
|
|
@@ -243,7 +252,9 @@ export function openCapabilityStore(options) {
|
|
|
243
252
|
const settled = await readRecord(record).catch(() => null)
|
|
244
253
|
if (settled?.kind !== 'ok' || settled.digest !== next.digest) return { status: 'write-failed', error }
|
|
245
254
|
}
|
|
246
|
-
|
|
255
|
+
// The payload the CAS replaced, read under this same lock — the only
|
|
256
|
+
// provable "before" authority of this write (undefined: no record).
|
|
257
|
+
return { status: 'committed', revision, payload, receipt, committedOver: current.kind === 'ok' ? current.payload : undefined }
|
|
247
258
|
} finally {
|
|
248
259
|
await lock.release(dir, layout.name, acquired.token).catch(() => {})
|
|
249
260
|
}
|
|
@@ -302,5 +313,94 @@ export function openCapabilityStore(options) {
|
|
|
302
313
|
return serialize(layout.key, () => lock.clear(dir, layout.name, which, ownerToken))
|
|
303
314
|
}
|
|
304
315
|
|
|
305
|
-
|
|
316
|
+
/** Synchronous record read — the same decode/fail-closed semantics as
|
|
317
|
+
* readRecord, blocking. @param {string} path @returns {RecordRead} */
|
|
318
|
+
function readRecordSync(path) {
|
|
319
|
+
let text
|
|
320
|
+
try {
|
|
321
|
+
text = syncFs.readTextSync(path)
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (hasCode(error, 'ENOENT')) return { kind: 'absent', revision: 0 }
|
|
324
|
+
throw error
|
|
325
|
+
}
|
|
326
|
+
return decodeRecord(text)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Synchronous read (capabilities-deepening D4): the listener path may not
|
|
331
|
+
* yield the event loop. Same unit layout, same record schema, same
|
|
332
|
+
* fail-closed kinds as the async read.
|
|
333
|
+
* @param {Unit} unit
|
|
334
|
+
* @returns {RecordRead | { kind: 'unsupported', reason: string }}
|
|
335
|
+
*/
|
|
336
|
+
function readSync(unit) {
|
|
337
|
+
const { record } = locate(unit)
|
|
338
|
+
if (!support.supported) return { kind: 'unsupported', reason: support.reason }
|
|
339
|
+
return readRecordSync(record)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Synchronous CAS commit (capabilities-deepening D4), mirroring transact's
|
|
344
|
+
* step order: lock → dedup the request receipt → CAS the revision → mutate
|
|
345
|
+
* → temp+fsync → rename → directory fsync. The sync lock is the nominal-
|
|
346
|
+
* lease convention (acquired, used, released in one blocking segment):
|
|
347
|
+
* contention fails closed as 'locked' — never a backoff, never a second
|
|
348
|
+
* reclamation protocol. `mutate` must be synchronous.
|
|
349
|
+
* @param {Unit} unit
|
|
350
|
+
* @param {number} expectedRevision
|
|
351
|
+
* @param {(payload: unknown, info: { revision: number }) => unknown} mutate
|
|
352
|
+
* @param {RequestRef} [request]
|
|
353
|
+
* @returns {CommitResult}
|
|
354
|
+
*/
|
|
355
|
+
function commitSync(unit, expectedRevision, mutate, request = {}) {
|
|
356
|
+
const { layout, dir, record } = locate(unit)
|
|
357
|
+
if (!support.supported) return { status: 'unsupported', reason: support.reason }
|
|
358
|
+
const { requestId, requestDigest } = request
|
|
359
|
+
if (requestId !== undefined && (typeof requestId !== 'string' || requestId.length === 0 || typeof requestDigest !== 'string')) {
|
|
360
|
+
throw new TypeError('requestId requires a non-empty id and a requestDigest')
|
|
361
|
+
}
|
|
362
|
+
syncFs.mkdirpSync(dir)
|
|
363
|
+
const acquired = syncLock.acquire(dir, layout.name)
|
|
364
|
+
if (!acquired.ok) return { status: 'locked', reason: acquired.reason }
|
|
365
|
+
try {
|
|
366
|
+
const current = readRecordSync(record)
|
|
367
|
+
if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'unreadable', kind: current.kind }
|
|
368
|
+
const receipts = current.kind === 'ok' ? current.receipts : []
|
|
369
|
+
if (requestId !== undefined) {
|
|
370
|
+
const prior = receipts.find(r => r.requestId === requestId)
|
|
371
|
+
if (prior) return prior.requestDigest === requestDigest ? { status: 'duplicate', revision: prior.revision, receipt: prior } : { status: 'request-conflict', receipt: prior }
|
|
372
|
+
}
|
|
373
|
+
if (current.revision !== expectedRevision) return { status: 'revision-conflict', revision: current.revision }
|
|
374
|
+
const revision = current.revision + 1
|
|
375
|
+
const payload = mutate(current.kind === 'ok' ? current.payload : undefined, { revision: current.revision })
|
|
376
|
+
/** @type {Receipt|null} */
|
|
377
|
+
const receipt = requestId === undefined ? null : { requestId, requestDigest: /** @type {string} */ (requestDigest), revision, acceptedAt: now() }
|
|
378
|
+
const next = encodeRecord(revision, payload, receipt ? [...receipts, receipt] : receipts)
|
|
379
|
+
const temp = join(dir, `${layout.name}.${acquired.token}.tmp`)
|
|
380
|
+
try {
|
|
381
|
+
syncFs.writeFullSync(temp, JSON.stringify(next))
|
|
382
|
+
syncFs.fsyncFileSync(temp)
|
|
383
|
+
} catch (error) {
|
|
384
|
+
try { syncFs.unlinkSync(temp) } catch { /* the temp may never have landed */ }
|
|
385
|
+
return { status: 'write-failed', error }
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
syncFs.renameSync(temp, record)
|
|
389
|
+
syncFs.fsyncDirSync(dir)
|
|
390
|
+
} catch (error) {
|
|
391
|
+
try { syncFs.unlinkSync(temp) } catch { /* the temp may never have landed */ }
|
|
392
|
+
if (hasCode(error, 'EXDEV')) return { status: 'unsupported', reason: 'cross-device' }
|
|
393
|
+
// Same indeterminate settle-check as the async path: the rename may or
|
|
394
|
+
// may not have landed — only a digest match proves it did.
|
|
395
|
+
let settled
|
|
396
|
+
try { settled = readRecordSync(record) } catch { settled = null }
|
|
397
|
+
if (settled?.kind !== 'ok' || settled.digest !== next.digest) return { status: 'write-failed', error }
|
|
398
|
+
}
|
|
399
|
+
// Same committed-over authority as the async path (one typedef, both writes).
|
|
400
|
+
return { status: 'committed', revision, payload, receipt, committedOver: current.kind === 'ok' ? current.payload : undefined }
|
|
401
|
+
} finally {
|
|
402
|
+
try { syncLock.release(dir, layout.name, acquired.token) } catch { /* release is best-effort */ }
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { support, read, commit, commitSync, readSync, publishPointer, inspect, clear, withPresetNamespace, withSelectionSnapshot }
|
|
306
406
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Supervised group coordinator: registry state machine for grouped delegation.
|
|
2
2
|
// Pure logic (no DSH imports) — the mount layer (index.js/tool.js) feeds it
|
|
3
3
|
// session events and injects the effectors (sendMessage/interrupt/timers).
|
|
4
|
+
import { isTerminalStatus, parseTerminalStatus } from '../shared/terminal-evidence.js'
|
|
4
5
|
|
|
5
6
|
export const SUPERVISION_CONTRACT = `
|
|
6
7
|
|
|
@@ -30,15 +31,6 @@ export const DEFAULT_SUPERVISION = {
|
|
|
30
31
|
/** Bounded wait for the last settlement notice before the fallback delivery. */
|
|
31
32
|
export const SIGNAL_FALLBACK_MS = 1000
|
|
32
33
|
|
|
33
|
-
/** Parse a child's final assistant text into a terminal status, or null. */
|
|
34
|
-
export function parseTerminalStatus(text) {
|
|
35
|
-
if (typeof text !== 'string' || text.length === 0) return null
|
|
36
|
-
const match = /^STATUS:[ \t]*(completed|blocked)[ \t]*$/m.exec(text)
|
|
37
|
-
if (!match) return null
|
|
38
|
-
const reportMatch = /^REPORT:[ \t]*([\s\S]*)$/m.exec(text.slice(match.index))
|
|
39
|
-
return { status: match[1], report: (reportMatch?.[1] ?? '').trim() }
|
|
40
|
-
}
|
|
41
|
-
|
|
42
34
|
/**
|
|
43
35
|
* Canonical member record shape: eight fields, fixed order. The single source
|
|
44
36
|
* every construction site converges on (registerMember, hydrate, rehydrate).
|
|
@@ -54,14 +46,6 @@ export function createGroupRecord(name, { sealed = false, settled = false, membe
|
|
|
54
46
|
return { name, memberIds: [...memberIds], sealed, settled }
|
|
55
47
|
}
|
|
56
48
|
|
|
57
|
-
/**
|
|
58
|
-
* Single exit for the terminal-status predicate: statuses that count toward
|
|
59
|
-
* group completion (blocked waits for a resume, so it is NOT terminal here).
|
|
60
|
-
*/
|
|
61
|
-
export function isTerminalStatus(status) {
|
|
62
|
-
return status === 'completed' || status === 'terminated'
|
|
63
|
-
}
|
|
64
|
-
|
|
65
49
|
/**
|
|
66
50
|
* Single exit for the untracked-catalog predicate: continuable catalog entries
|
|
67
51
|
* the registry does not track. mode is normalized (undefined → 'continuable');
|
package/src/delegate/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Weir delegate plugin: category registry + curated agents + the delegate
|
|
2
2
|
// tool. Plain ESM, ctx-only. Composition root: all policy lives in the sibling
|
|
3
3
|
// modules (settings-overlay / target-resolver / supervision-tools /
|
|
4
|
-
// supervision-mount
|
|
4
|
+
// supervision-mount); apply() only constructs and wires them. The terminal-
|
|
5
|
+
// evidence cold readers live in src/shared/terminal-evidence.js (shared with
|
|
6
|
+
// the worktree plugin).
|
|
5
7
|
import { CURATED_AGENTS } from './agents.js'
|
|
6
8
|
import { createSettingsOverlay } from './settings-overlay.js'
|
|
7
9
|
import { createTargetResolver } from './target-resolver.js'
|
|
@@ -11,7 +13,7 @@ import { skillSelectionFor } from '../capabilities/skill-selection-plugin.js'
|
|
|
11
13
|
import { createResumeTool, createTerminateTool } from './supervision-tools.js'
|
|
12
14
|
import { mountSupervision } from './supervision-mount.js'
|
|
13
15
|
import { createAudit } from '../shared/audit.js'
|
|
14
|
-
import { FALLBACK_TABLES } from '
|
|
16
|
+
import { FALLBACK_TABLES } from './whitelist-defaults.js'
|
|
15
17
|
import { createDelegateTool } from './tool.js'
|
|
16
18
|
import { attachWorktreeModeGuard } from './worktree-mode.js'
|
|
17
19
|
import { DOCTRINE_SECTION_ORDER } from '../core/doctrine.js'
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// restart. Pure logic — the mount layer feeds it parsed audit records and the
|
|
3
3
|
// DSH catalog; no DSH imports, no fs.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { createMemberRecord, createGroupRecord, untrackedCatalogEntries } from './group-coordinator.js'
|
|
6
|
+
import { parseTerminalStatus, isTerminalStatus } from '../shared/terminal-evidence.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Rebuild supervision state by replaying structured audit facts and
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Read-only shell guard core: the single canonical home of every cross-shell
|
|
2
2
|
// policy the bash and pwsh adapters share — the whitelist tables (one
|
|
3
3
|
// representation, consumed by the guard adapters, delegate/index.js, and
|
|
4
|
-
//
|
|
4
|
+
// whitelist-defaults.js), the git gate, the GIT_CONFIG_* environment
|
|
5
5
|
// smuggle refusal, the recursion budget, the redirect sink policy, and the
|
|
6
6
|
// per-executable verdict tail. Pure functions, no ctx access.
|
|
7
7
|
//
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// the three & roles, alias expansion), so the scanners are not merged.
|
|
11
11
|
|
|
12
12
|
/** Canonical whitelist tables, organized by the five published keys so
|
|
13
|
-
*
|
|
13
|
+
* whitelist-defaults.js derives FALLBACK_TABLES with zero translation.
|
|
14
14
|
* Entries moved verbatim from the former DEFAULT_ROBASH (robash-guard.js) and
|
|
15
15
|
* DEFAULT_ROBASH_PWSH (robash-guard-pwsh.js); pwsh entries keep their original
|
|
16
16
|
* casing (the pwsh adapter lowercases at lookup) so the shipped
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// into live coordinators. This module is a ctx adapter — per the jsconfig
|
|
5
5
|
// purity curation it stays out of include (same precedent as index.js and
|
|
6
6
|
// group-coordinator.js).
|
|
7
|
-
import { createGroupCoordinator
|
|
7
|
+
import { createGroupCoordinator } from './group-coordinator.js'
|
|
8
8
|
import { rehydrateSupervision, applyChildLogRecovery } from './rehydrate.js'
|
|
9
|
-
import { auditFilePathOf, readAuditTail, readChildFinalText } from '
|
|
9
|
+
import { auditFilePathOf, isTerminalStatus, readAuditTail, readChildFinalText } from '../shared/terminal-evidence.js'
|
|
10
10
|
import { AUDIT_TYPES } from '../shared/audit.js'
|
|
11
11
|
import { userTextMessage } from '../shared/user-message.js'
|
|
12
12
|
import { contentText } from '../shared/content-text.js'
|
|
@@ -75,8 +75,9 @@ export function mountSupervision({ ctx, audit, settings, supervisionNow, onChild
|
|
|
75
75
|
audit(parent.session, `${AUDIT_TYPES.supervision}/${fact.kind}`, fact)
|
|
76
76
|
// A settled or terminated member frees its worktree lane (the host
|
|
77
77
|
// checks the lane) — TERMINAL outcomes only (resumable-lane-workers
|
|
78
|
-
// D1)
|
|
79
|
-
//
|
|
78
|
+
// D1), with terminality defined by the shared isTerminalStatus
|
|
79
|
+
// predicate: a blocked member's binding must survive a possible
|
|
80
|
+
// resume; the blocked fact still rides the audit channel.
|
|
80
81
|
// Deferred: never run lane work inside the fact.
|
|
81
82
|
const settlesLane = fact.kind === 'terminate' || (fact.kind === 'settle' && isTerminalStatus(fact.status))
|
|
82
83
|
if (settlesLane && !handoffs.has(fact.childId)) {
|
package/src/delegate/tool.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// @deepseek-ai packages do not resolve from a linked bundle).
|
|
4
4
|
import { parseEscalation } from './escalate.js'
|
|
5
5
|
import { continuableLane, oneShotLane, spawnGuardedChild, supervisedLane } from './spawn-adapter.js'
|
|
6
|
+
import { WORKTREE_CODES } from '../worktree/errors.js'
|
|
6
7
|
import { contentText } from '../shared/content-text.js'
|
|
7
8
|
import { settleDelegateEditLifecycle } from '../shared/delegate-edit-lifecycle.js'
|
|
8
9
|
|
|
@@ -473,16 +474,38 @@ async function laneBindingFor(target, args, deps, exec) {
|
|
|
473
474
|
}
|
|
474
475
|
}
|
|
475
476
|
|
|
476
|
-
/**
|
|
477
|
+
/**
|
|
478
|
+
* Spawn under a reserved binding: commit the child id, or roll back on
|
|
479
|
+
* failure. A commit refusing LANE_RESERVATION_LOST means the reservation was
|
|
480
|
+
* reclaimed while the child spawned (lane-binding-authoritative-commit): the
|
|
481
|
+
* child was never authorised, so it is disposed BEFORE the error surfaces
|
|
482
|
+
* (one-shot dispose precedent: spawn-adapter.js onGuardFailure). The rollback
|
|
483
|
+
* afterwards is a nonce-conditional no-op by then (D5). A spawn failure has
|
|
484
|
+
* no child to tear down; supervised-group commits roll back in the group
|
|
485
|
+
* catch (terminate per member), never here.
|
|
486
|
+
*/
|
|
477
487
|
async function spawnBound(binding, spawn, idOf) {
|
|
488
|
+
let started
|
|
489
|
+
try {
|
|
490
|
+
started = await spawn()
|
|
491
|
+
} catch (error) {
|
|
492
|
+
await binding?.rollback().catch(() => {})
|
|
493
|
+
throw error
|
|
494
|
+
}
|
|
478
495
|
try {
|
|
479
|
-
const started = await spawn()
|
|
480
496
|
await binding?.commit(idOf(started))
|
|
481
|
-
return started
|
|
482
497
|
} catch (error) {
|
|
498
|
+
if (/** @type {any} */ (error)?.code === WORKTREE_CODES.LANE_RESERVATION_LOST) {
|
|
499
|
+
try {
|
|
500
|
+
await started.dispose?.()
|
|
501
|
+
} catch {
|
|
502
|
+
// best-effort teardown of the unauthorised child
|
|
503
|
+
}
|
|
504
|
+
}
|
|
483
505
|
await binding?.rollback().catch(() => {})
|
|
484
506
|
throw error
|
|
485
507
|
}
|
|
508
|
+
return started
|
|
486
509
|
}
|
|
487
510
|
|
|
488
511
|
/** Lane guard spec with the path resolver bound to the parent's cwd. */
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// so tests can drive it without a runtime.
|
|
24
24
|
|
|
25
25
|
import { readFileSync } from 'node:fs'
|
|
26
|
-
import { DEFAULT_TABLES } from '
|
|
26
|
+
import { DEFAULT_TABLES } from './robash-guard-core.js'
|
|
27
27
|
/** @typedef {{ readFile?: (path: string | URL) => string, logger?: { warn?: (message: string) => void } }} ReaderOptions */
|
|
28
28
|
|
|
29
29
|
/** The five table names, in the order they are published. */
|
|
@@ -51,7 +51,7 @@ export const FALLBACK_TABLES = Object.freeze({
|
|
|
51
51
|
/**
|
|
52
52
|
* The shipped defaults file, resolved against THIS module's location so it
|
|
53
53
|
* travels with the bundle rather than with whatever working directory the host
|
|
54
|
-
* happens to have. `src/
|
|
54
|
+
* happens to have. `src/delegate/` → bundle root.
|
|
55
55
|
*/
|
|
56
56
|
export const DEFAULT_WHITELIST_PATH = new URL('../../whitelist-defaults.json', import.meta.url)
|
|
57
57
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// of publication outcome or old-writer quiescence. It lives in the atomic image.
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import { canonical } from './snapshot.js'
|
|
5
|
+
import { revokedOwner } from './derived-facts.js'
|
|
5
6
|
import { expectedRegistry, isRegistryUpgrade } from './incarnations.js'
|
|
6
7
|
|
|
7
8
|
export const LATE_WRITER_RISK = 'Detached historic writers may still modify files after this override.'
|
|
@@ -12,17 +13,13 @@ export const ONLINE_RECOVERY_ACTOR = 'online-administrator'
|
|
|
12
13
|
* settlement (design D3) are the only writers. */
|
|
13
14
|
const ACTORS = ['authenticated-settings-administrator', AUTOMATIC_RECOVERY_ACTOR, ONLINE_RECOVERY_ACTOR]
|
|
14
15
|
export const digest = value => createHash('sha256').update(canonical(value)).digest('hex')
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
export
|
|
22
|
-
const session = state.sessions?.find(s => s.sessionId === owner)
|
|
23
|
-
if (!session) return false
|
|
24
|
-
return state.adminRecoveries?.some(row => row.owner === owner && session.executionEpoch <= row.revokedEpoch) === true
|
|
25
|
-
}
|
|
16
|
+
// Epoch-scoped revocation (`revokedOwner`) and the disposition lookup
|
|
17
|
+
// (`dispositionFor`) live in derived-facts.js (change edit-lock-deepening,
|
|
18
|
+
// design D1) — one pure home shared by the manager status path, the cold view
|
|
19
|
+
// and the inspector; `revokedOwner` is imported above for this module's own
|
|
20
|
+
// validation. The re-export keeps existing external importers working (the
|
|
21
|
+
// integration scenario child imports dispositionFor from this module).
|
|
22
|
+
export { dispositionFor, revokedOwner } from './derived-facts.js'
|
|
26
23
|
|
|
27
24
|
function requireThat(condition, message) { if (!condition) throw new Error(`invalid administrative recovery: ${message}`) }
|
|
28
25
|
function keys(value, names) {
|
|
@@ -6,7 +6,8 @@ import { createHash } from 'node:crypto'
|
|
|
6
6
|
import { reservePublisher } from './reservation.js'
|
|
7
7
|
import { openEditLockStore } from './store.js'
|
|
8
8
|
import { canonical, parseSnapshot, validateImage } from './snapshot.js'
|
|
9
|
-
import { administrativeState, digest,
|
|
9
|
+
import { administrativeState, digest, LATE_WRITER_RISK } from './admin-ledger.js'
|
|
10
|
+
import { dispositionFor } from './derived-facts.js'
|
|
10
11
|
|
|
11
12
|
export const MAX_RECOVERY_BYTES = 16 * 1024 * 1024
|
|
12
13
|
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
|
|
@@ -2,8 +2,15 @@ import { posix, win32 } from 'node:path'
|
|
|
2
2
|
|
|
3
3
|
/** Candidates describe effects, not merely explicit API arguments. Session-wide
|
|
4
4
|
* operations must enumerate affected ownership at their FIFO position.
|
|
5
|
+
* Modes: 'normal' covers every mutating ingress; 'release' is pure removal and
|
|
6
|
+
* must retain the ownership unresolved updates require; 'revocation' is strictly
|
|
7
|
+
* subtractive and crosses fences. 'resume' is reserved for session-level
|
|
8
|
+
* recovery transactions (the trusted Continue): it publishes nothing, so an
|
|
9
|
+
* unresolved resource-level fence never denies it, while domain fences and
|
|
10
|
+
* every shape denial bind it exactly as they bind 'normal'. It must never be
|
|
11
|
+
* used for a transaction that publishes, confirms, acquires or releases.
|
|
5
12
|
* @typedef {{kind: 'resources', resourceIds: string[]} | {kind: 'scope', ancestor: string} | {kind: 'none'}} Candidate
|
|
6
|
-
* @typedef {'normal'|'release'|'revocation'} Mode
|
|
13
|
+
* @typedef {'normal'|'release'|'revocation'|'resume'} Mode
|
|
7
14
|
*/
|
|
8
15
|
|
|
9
16
|
/** Shape check only, NOT filesystem proof. The trusted manager ingress already
|
|
@@ -59,7 +66,10 @@ export function admitMutation(operations, candidate, mode = 'normal') {
|
|
|
59
66
|
for (const resourceId of candidate.resourceIds) {
|
|
60
67
|
if (!canonicalResource(resourceId)) deny('identity-unproved')
|
|
61
68
|
if (fence?.kind !== 'resource') deny('scope-continuity-unproved')
|
|
62
|
-
|
|
69
|
+
// 'resume' publishes nothing: a resource-level fence never bars the
|
|
70
|
+
// session-level recovery transaction; it keeps barring the resource
|
|
71
|
+
// itself on every other mode until administrative settlement.
|
|
72
|
+
if (mode !== 'resume' && fence?.kind === 'resource' && (!canonicalResource(fence.resourceId) || fence.resourceId === resourceId)) deny('resource-conflict')
|
|
63
73
|
}
|
|
64
74
|
}
|
|
65
75
|
}
|