@dzhechkov/harness-core 0.5.0 → 0.5.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/.dz-manifest.json +339 -75
- package/README.md +47 -4
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +39 -2
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks-verify.d.ts +23 -2
- package/dist/codex-hooks-verify.d.ts.map +1 -1
- package/dist/codex-hooks-verify.js +29 -0
- package/dist/codex-hooks-verify.js.map +1 -1
- package/dist/codex-hooks.d.ts +90 -7
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +171 -21
- package/dist/codex-hooks.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +22 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +45 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -2
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-plan.d.ts +70 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +103 -0
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +38 -130
- package/dist/loop-render.js.map +1 -1
- package/dist/loop-run-semantics.d.ts +130 -0
- package/dist/loop-run-semantics.d.ts.map +1 -0
- package/dist/loop-run-semantics.js +257 -0
- package/dist/loop-run-semantics.js.map +1 -0
- package/dist/loop-trace.d.ts +106 -5
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +151 -18
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +10 -0
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +17 -5
- package/dist/managed-hooks.js.map +1 -1
- package/dist/named-lock.d.ts +57 -0
- package/dist/named-lock.d.ts.map +1 -0
- package/dist/named-lock.js +247 -0
- package/dist/named-lock.js.map +1 -0
- package/dist/operations.d.ts +81 -5
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +356 -38
- package/dist/operations.js.map +1 -1
- package/dist/parity.d.ts +70 -2
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +133 -2
- package/dist/parity.js.map +1 -1
- package/dist/qe-bridge.d.ts +291 -0
- package/dist/qe-bridge.d.ts.map +1 -0
- package/dist/qe-bridge.js +538 -0
- package/dist/qe-bridge.js.map +1 -0
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +43 -9
- package/dist/score.js.map +1 -1
- package/dist/trace-corroborate.d.ts +48 -0
- package/dist/trace-corroborate.d.ts.map +1 -0
- package/dist/trace-corroborate.js +172 -0
- package/dist/trace-corroborate.js.map +1 -0
- package/dist/workflow-run-dispatch.d.ts +230 -0
- package/dist/workflow-run-dispatch.d.ts.map +1 -0
- package/dist/workflow-run-dispatch.js +363 -0
- package/dist/workflow-run-dispatch.js.map +1 -0
- package/dist/workflow-run.d.ts +513 -0
- package/dist/workflow-run.d.ts.map +1 -0
- package/dist/workflow-run.js +1377 -0
- package/dist/workflow-run.js.map +1 -0
- package/package.json +2 -2
- package/sbom.json +740 -80
- package/src/codex-hooks-assets.ts +39 -2
- package/src/codex-hooks-verify.ts +55 -2
- package/src/codex-hooks.ts +172 -20
- package/src/feature-adr-routing.ts +55 -0
- package/src/index.ts +46 -1
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-plan.ts +185 -0
- package/src/loop-render.ts +38 -128
- package/src/loop-run-semantics.ts +278 -0
- package/src/loop-trace.ts +207 -16
- package/src/managed-hooks.ts +26 -5
- package/src/named-lock.ts +277 -0
- package/src/operations.ts +441 -40
- package/src/parity.ts +177 -2
- package/src/qe-bridge.ts +737 -0
- package/src/score.ts +50 -9
- package/src/trace-corroborate.ts +205 -0
- package/src/workflow-run-dispatch.ts +459 -0
- package/src/workflow-run.ts +1773 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named advisory locks — `.dz/locks/<name>.lock` (feature qe-bridge-claude, ADR-001 D4-A).
|
|
3
|
+
*
|
|
4
|
+
* WHY. The pattern store already has a cross-process lock (`store-lock.ts`), but it guards ONE
|
|
5
|
+
* resource. Other read-modify-write surfaces in this repo have the same lost-update shape and no
|
|
6
|
+
* guard — the first of them being the `$CODEX_HOME/hooks.json` merge writer, whose race was
|
|
7
|
+
* ACCEPTED as a degradation with an explicit exit condition naming this leg
|
|
8
|
+
* (`architecture/degradations.md`). A read-modify-write that two processes interleave loses one
|
|
9
|
+
* side's addition, and BOTH report success — atomicity (temp+rename) prevents corruption; only
|
|
10
|
+
* mutual exclusion prevents a lost update.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THIS IS. The same `proper-lockfile` machinery as `withStoreLockSync`: atomic `mkdir`
|
|
13
|
+
* acquisition, heartbeat-refreshed staleness (age alone is never liveness), a real acquisition
|
|
14
|
+
* DEADLINE, and a LOUD throw on timeout rather than proceeding unlocked — proceeding would restore
|
|
15
|
+
* exactly the silent lost update this exists to stop. It is emphatically NOT the hand-rolled `wx`
|
|
16
|
+
* file lock that an independent review graded F (`store-lock.ts:14-31`).
|
|
17
|
+
*
|
|
18
|
+
* WHY A MIRROR AND NOT A CALL. `store-lock.ts` keeps its internals private and this feature's
|
|
19
|
+
* out-of-scope fence forbids changing the store lock's semantics, so the ~40 lines of acquisition
|
|
20
|
+
* machinery are mirrored here rather than extracted. The duplication is deliberate and is a named
|
|
21
|
+
* unification candidate; the options type, the stale/timeout defaults and the environment override
|
|
22
|
+
* are IMPORTED from `store-lock.ts`, so the two cannot drift apart on their contract.
|
|
23
|
+
*
|
|
24
|
+
* HONEST LIMIT. This is an ADVISORY lock: it serializes writers that TAKE it. A foreign process
|
|
25
|
+
* that never heard of it writes whenever it likes. For `hooks.json` the pre-existing mitigations
|
|
26
|
+
* (foreign entries preserved byte-for-byte, timestamped backup, atomic temp+rename) remain the
|
|
27
|
+
* backstop for that case, and the rule doc says so in as many words.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { randomBytes } from 'node:crypto';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import { join, resolve } from 'node:path';
|
|
34
|
+
import { lockSync } from 'proper-lockfile';
|
|
35
|
+
|
|
36
|
+
import { LOCK_TIMEOUT_MS, STALE_LOCK_MS } from './store-lock.js';
|
|
37
|
+
import type { StoreLockOptions } from './store-lock.js';
|
|
38
|
+
|
|
39
|
+
/** `proper-lockfile` silently clamps `stale` up to this minimum. */
|
|
40
|
+
const MIN_STALE_MS = 2_000;
|
|
41
|
+
|
|
42
|
+
/** Lock names are filenames: one bounded, lowercase, path-free component. */
|
|
43
|
+
const SAFE_LOCK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
44
|
+
|
|
45
|
+
export function isSafeLockName(name: string): boolean {
|
|
46
|
+
return typeof name === 'string' && SAFE_LOCK_NAME.test(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Path of a named lock (a DIRECTORY while held). Throws on an unsafe name — a lock whose path a
|
|
50
|
+
* caller can steer is not a lock. */
|
|
51
|
+
export function namedLockPath(projectRoot: string, name: string): string {
|
|
52
|
+
if (!isSafeLockName(name)) {
|
|
53
|
+
throw new NamedLockNameError(name);
|
|
54
|
+
}
|
|
55
|
+
return join(projectRoot, '.dz', 'locks', `${name}.lock`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class NamedLockNameError extends Error {
|
|
59
|
+
readonly code = 'ELOCKNAME';
|
|
60
|
+
constructor(name: string) {
|
|
61
|
+
super(
|
|
62
|
+
`invalid lock name ${JSON.stringify(name)} — a lock name must match ${String(SAFE_LOCK_NAME)} ` +
|
|
63
|
+
'(one lowercase path-free component); a name carrying a separator would place the lock outside .dz/locks/',
|
|
64
|
+
);
|
|
65
|
+
this.name = 'NamedLockNameError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Acquisition gave up at the deadline. Nothing ran; the operation can be retried. */
|
|
70
|
+
export class NamedLockTimeoutError extends Error {
|
|
71
|
+
readonly code = 'ELOCKTIMEOUT';
|
|
72
|
+
constructor(lockPath: string, waitedMs: number) {
|
|
73
|
+
super(
|
|
74
|
+
`the lock at ${lockPath} stayed held for ${waitedMs}ms — another process is still inside the ` +
|
|
75
|
+
'guarded section. Nothing was written; retry.',
|
|
76
|
+
);
|
|
77
|
+
this.name = 'NamedLockTimeoutError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The lock was broken out from under the holder while `fn` ran: it DID run, but may have raced. */
|
|
82
|
+
export class NamedLockCompromisedError extends Error {
|
|
83
|
+
readonly code = 'ECOMPROMISED';
|
|
84
|
+
constructor(lockPath: string, cause: Error) {
|
|
85
|
+
super(`the lock at ${lockPath} was compromised while held (${cause.message}) — retry the operation.`);
|
|
86
|
+
this.name = 'NamedLockCompromisedError';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const defaultTimeoutMs = (): number => {
|
|
91
|
+
const env = Number(process.env['DZ_STORE_LOCK_TIMEOUT_MS']);
|
|
92
|
+
return Number.isFinite(env) && env > 0 ? env : LOCK_TIMEOUT_MS;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Synchronous sleep without spinning (Atomics.wait on a throwaway buffer). */
|
|
96
|
+
const sleepSync = (ms: number): void => {
|
|
97
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function resolveOpts(opts: StoreLockOptions): { staleMs: number; timeoutMs: number; pollMs: number } {
|
|
101
|
+
return {
|
|
102
|
+
staleMs: Math.max(opts.staleMs ?? STALE_LOCK_MS, MIN_STALE_MS),
|
|
103
|
+
timeoutMs: opts.timeoutMs ?? defaultTimeoutMs(),
|
|
104
|
+
pollMs: opts.pollMs ?? 25,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface Acquired {
|
|
109
|
+
/** Releases the lock. Returns `'released'` when it was ours, `'stolen'` when it was not (nothing
|
|
110
|
+
* removed, exit-time removal disarmed) and `'stolen-undisarmed'` when it was not ours AND the
|
|
111
|
+
* exit-time removal could not be disarmed — a gap the caller must hear about. */
|
|
112
|
+
readonly release: () => 'released' | 'stolen' | 'stolen-undisarmed';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* DISARMING THE LIBRARY'S EXIT HANDLER (round-2 CRITICAL C2, second mechanism).
|
|
117
|
+
*
|
|
118
|
+
* `proper-lockfile` registers a process-exit handler that `rmdirSync`s every lock still in its
|
|
119
|
+
* internal map — unconditionally, with no ownership check (lockfile.js: "Remove acquired locks on
|
|
120
|
+
* exit"). So even after this module refuses to remove a stolen lock, the holder's PROCESS EXIT
|
|
121
|
+
* removed the successor's live lock a moment later. MEASURED: process C then acquired the lock while
|
|
122
|
+
* B was still inside its critical section.
|
|
123
|
+
*
|
|
124
|
+
* The map is reachable through the library's own `getLocks()` accessor. It is not re-exported by the
|
|
125
|
+
* package index, so this is a deep import — guarded by a runtime shape check, and NEVER silent: if
|
|
126
|
+
* the accessor is missing (a future version moved it), the compromise error says the exit-time
|
|
127
|
+
* removal could not be disarmed, so the gap is reported rather than assumed away.
|
|
128
|
+
*/
|
|
129
|
+
const lockfileInternals: { getLocks?: () => Record<string, unknown> } = (() => {
|
|
130
|
+
try {
|
|
131
|
+
const req = createRequire(import.meta.url);
|
|
132
|
+
const mod = req('proper-lockfile/lib/lockfile.js') as { getLocks?: unknown };
|
|
133
|
+
return typeof mod.getLocks === 'function' ? { getLocks: mod.getLocks as () => Record<string, unknown> } : {};
|
|
134
|
+
} catch {
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
|
|
139
|
+
/** Drop a lock we no longer own from the library's exit-time removal list. Returns false when the
|
|
140
|
+
* accessor is unavailable — the caller says so out loud. */
|
|
141
|
+
function disarmExitRemoval(resourceKey: string): boolean {
|
|
142
|
+
const get = lockfileInternals.getLocks;
|
|
143
|
+
if (get === undefined) return false;
|
|
144
|
+
try {
|
|
145
|
+
const locks = get();
|
|
146
|
+
if (Object.prototype.hasOwnProperty.call(locks, resourceKey)) delete locks[resourceKey];
|
|
147
|
+
return true;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* OWNERSHIP TOKEN (round-2 CRITICAL C2). `proper-lockfile`'s release is unconditional: it clears its
|
|
155
|
+
* timer and removes the lock directory, without checking that the directory is still the one it
|
|
156
|
+
* created. That is safe while the heartbeat runs — but a SYNCHRONOUS critical section blocks the
|
|
157
|
+
* event loop, so the heartbeat cannot fire, a waiter is entitled to break the stale lock, and the
|
|
158
|
+
* original holder then deletes its SUCCESSOR's live lock on the way out.
|
|
159
|
+
*
|
|
160
|
+
* MEASURED (2026-08-19, three real processes, reproducer in named-lock.test.ts / red-green.md):
|
|
161
|
+
* with a 6s synchronous body under a 2s stale threshold, process C acquired the lock while B was
|
|
162
|
+
* still inside its critical section — mutual exclusion gone, silently.
|
|
163
|
+
*
|
|
164
|
+
* The fix: stamp a random token BESIDE the lock directory at acquisition and verify it at release. A
|
|
165
|
+
* lock that no longer carries our token is somebody else's, and we do not touch it — we report the
|
|
166
|
+
* compromise loudly instead. (The marker lives beside, not inside: `proper-lockfile` removes the
|
|
167
|
+
* lock with a plain `rmdir`, so a file within it would make every release silently fail and leak the
|
|
168
|
+
* lock — MEASURED, the first draft of this fix did exactly that and the suite went red.)
|
|
169
|
+
*/
|
|
170
|
+
function ownerMarkerPath(lockPath: string): string {
|
|
171
|
+
return lockPath + '.owner';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function tryAcquire(
|
|
175
|
+
projectRoot: string,
|
|
176
|
+
name: string,
|
|
177
|
+
staleMs: number,
|
|
178
|
+
onCompromised: (e: Error) => void,
|
|
179
|
+
): Acquired | 'held' {
|
|
180
|
+
const lockPath = namedLockPath(projectRoot, name);
|
|
181
|
+
const resourceKey = resolve(projectRoot, '.dz', 'locks', name);
|
|
182
|
+
try {
|
|
183
|
+
const release = lockSync(resourceKey, {
|
|
184
|
+
lockfilePath: lockPath,
|
|
185
|
+
stale: staleMs,
|
|
186
|
+
realpath: false, // the guarded resource is a concept, not an existing file
|
|
187
|
+
onCompromised,
|
|
188
|
+
});
|
|
189
|
+
const token = randomBytes(16).toString('hex');
|
|
190
|
+
let ino: number | null = null;
|
|
191
|
+
try {
|
|
192
|
+
writeFileSync(ownerMarkerPath(lockPath), token, { mode: 0o600 });
|
|
193
|
+
ino = statSync(lockPath).ino;
|
|
194
|
+
} catch { /* an unwritable lock dir: fall back to inode-only evidence below */ }
|
|
195
|
+
return {
|
|
196
|
+
release: (): 'released' | 'stolen' | 'stolen-undisarmed' => {
|
|
197
|
+
let ours = false;
|
|
198
|
+
try {
|
|
199
|
+
const seen = readFileSync(ownerMarkerPath(lockPath), 'utf8');
|
|
200
|
+
const sameIno = ino === null ? true : statSync(lockPath).ino === ino;
|
|
201
|
+
ours = seen === token && sameIno;
|
|
202
|
+
} catch {
|
|
203
|
+
ours = false; // gone, or unreadable — either way not provably ours
|
|
204
|
+
}
|
|
205
|
+
if (!ours) {
|
|
206
|
+
// NEVER remove a lock directory we cannot prove is ours — including at process exit.
|
|
207
|
+
return disarmExitRemoval(resourceKey) ? 'stolen' : 'stolen-undisarmed';
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
release();
|
|
211
|
+
} catch { /* ERELEASED after a compromise — the lock is no longer ours to remove */ }
|
|
212
|
+
try {
|
|
213
|
+
rmSync(ownerMarkerPath(lockPath), { force: true });
|
|
214
|
+
} catch { /* best effort: a stale marker is overwritten by the next holder anyway */ }
|
|
215
|
+
return 'released';
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if ((err as NodeJS.ErrnoException).code === 'ELOCKED') return 'held';
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Run `fn` while holding the named lock, releasing it however `fn` ends.
|
|
226
|
+
*
|
|
227
|
+
* Throws {@link NamedLockTimeoutError} when the lock cannot be acquired by the deadline (nothing
|
|
228
|
+
* has run) and {@link NamedLockCompromisedError} when the lock was broken while `fn` ran. The
|
|
229
|
+
* heartbeat cannot fire while a synchronous `fn` blocks the event loop, so keep bodies well under
|
|
230
|
+
* `staleMs` — the same caveat `withStoreLockSync` carries.
|
|
231
|
+
*/
|
|
232
|
+
export function withNamedLockSync<T>(
|
|
233
|
+
projectRoot: string,
|
|
234
|
+
name: string,
|
|
235
|
+
fn: () => T,
|
|
236
|
+
opts: StoreLockOptions = {},
|
|
237
|
+
): T {
|
|
238
|
+
const lockPath = namedLockPath(projectRoot, name); // validates the name BEFORE any mkdir
|
|
239
|
+
const { staleMs, timeoutMs, pollMs } = resolveOpts(opts);
|
|
240
|
+
mkdirSync(join(projectRoot, '.dz', 'locks'), { recursive: true });
|
|
241
|
+
const started = Date.now();
|
|
242
|
+
const deadline = started + timeoutMs;
|
|
243
|
+
let compromised: Error | undefined;
|
|
244
|
+
const onCompromised = (e: Error): void => { compromised = e; };
|
|
245
|
+
for (;;) {
|
|
246
|
+
const got = tryAcquire(projectRoot, name, staleMs, onCompromised);
|
|
247
|
+
if (got !== 'held') {
|
|
248
|
+
let result: T;
|
|
249
|
+
let outcome: 'released' | 'stolen' | 'stolen-undisarmed' = 'released';
|
|
250
|
+
try {
|
|
251
|
+
result = fn();
|
|
252
|
+
} finally {
|
|
253
|
+
outcome = got.release();
|
|
254
|
+
}
|
|
255
|
+
if (outcome !== 'released') {
|
|
256
|
+
throw new NamedLockCompromisedError(
|
|
257
|
+
lockPath,
|
|
258
|
+
compromised ??
|
|
259
|
+
new Error(
|
|
260
|
+
'the lock directory no longer carries this holder\u2019s ownership token — a waiter broke it as stale ' +
|
|
261
|
+
'while the critical section was still running (a synchronous body cannot heartbeat). The successor\u2019s ' +
|
|
262
|
+
'lock was left untouched; THIS run must be treated as having raced and retried.' +
|
|
263
|
+
(outcome === 'stolen-undisarmed'
|
|
264
|
+
? ' WARNING: proper-lockfile\u2019s exit-time lock removal could NOT be disarmed (its getLocks() accessor ' +
|
|
265
|
+
'is unavailable in this version), so this process may still remove the successor\u2019s lock when it exits.'
|
|
266
|
+
: ''),
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (compromised !== undefined) throw new NamedLockCompromisedError(lockPath, compromised);
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
if (now >= deadline) throw new NamedLockTimeoutError(lockPath, now - started);
|
|
275
|
+
sleepSync(Math.min(pollMs, deadline - now));
|
|
276
|
+
}
|
|
277
|
+
}
|