cli-relay 1.0.1 → 1.0.3
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/README.md +84 -7
- package/cli-relay.mjs +55 -11
- package/package.json +1 -1
- package/src/commands/doctor.mjs +13 -0
- package/src/core/lock.mjs +113 -33
- package/src/core/pins.mjs +5 -1
package/README.md
CHANGED
|
@@ -6,6 +6,23 @@ Resume-by-reference delegation across codex, agy, claude-code (command-code: fre
|
|
|
6
6
|
Built 2026-08-16/17, inspired by DeepSeek Harness's subagent architecture but deliberately
|
|
7
7
|
smaller — see design history below before re-deriving any of this from scratch.
|
|
8
8
|
|
|
9
|
+
## Built for your agent to drive, not just for you to type
|
|
10
|
+
|
|
11
|
+
cli-relay is designed to be called *by your own coding agent* as a tool, not only typed by
|
|
12
|
+
hand. Every `fresh`/`resume` call returns one structured JSON object on stdout — a native
|
|
13
|
+
session id, the model's answer, exit code, timing/signal info — specifically so an agent can
|
|
14
|
+
parse the result and decide what to do next without guessing at free-form text. Exit codes are
|
|
15
|
+
deliberate and stable (0 success, 1 general error — including `doctor` when no backend at
|
|
16
|
+
all is usable, 2 usage error, 3 "ran but produced no usable answer" — see Exit
|
|
17
|
+
codes below) so an agent's own control flow can branch on them directly.
|
|
18
|
+
|
|
19
|
+
In practice that means: point your daily-driver agent (Claude Code, Codex, whatever you're
|
|
20
|
+
already using) at this README and ask it to use `cli-relay` to delegate a task to a different
|
|
21
|
+
backend, or to resume a thread from three days ago — it has everything it needs in this file
|
|
22
|
+
and in `cli-relay doctor`'s output to do that itself, without you hand-holding it through the
|
|
23
|
+
CLI's own flags. Nothing about this requires an agent, though — every command works exactly the
|
|
24
|
+
same typed directly into a terminal by hand.
|
|
25
|
+
|
|
9
26
|
## Installation
|
|
10
27
|
|
|
11
28
|
Requires Node.js 18.17 or newer.
|
|
@@ -44,9 +61,16 @@ cli-relay pins <thread>
|
|
|
44
61
|
Backends: `codex`, `agy`, `claude-code`, `command-code` (command-code is fresh-only —
|
|
45
62
|
its resume showed a reproducible seed-turn bug live, see file header).
|
|
46
63
|
|
|
47
|
-
`cli-relay doctor` checks that
|
|
64
|
+
`cli-relay doctor` checks that each backend's binary is actually resolvable on PATH —
|
|
48
65
|
useful after a fresh machine setup or when a backend call fails and you're not sure whether
|
|
49
|
-
it's cli-relay or the backend itself.
|
|
66
|
+
it's cli-relay or the backend itself. It exits 1 only when *no* backend at all is usable
|
|
67
|
+
(zero of the built-in codex/agy/claude-code/command-code binaries resolve) — a genuinely
|
|
68
|
+
broken install with nothing working. A partial install (1-3 of the four present) is the
|
|
69
|
+
documented normal setup (see Prerequisites: "you don't need all four") and exits 0, so the
|
|
70
|
+
gate catches "nothing works here" without punishing "not everything installed". Custom user
|
|
71
|
+
adapters are reported in its output but don't affect the exit code (they're optional by
|
|
72
|
+
definition). `--dry-run` /
|
|
73
|
+
`--print-command` prints the exact argv
|
|
50
74
|
that would be spawned (prompt fully assembled, pins injected) without spawning anything or
|
|
51
75
|
touching the session map at all — it enforces the same refusals a real run would (e.g. won't
|
|
52
76
|
preview a `resume` on an unconfirmed thread, won't preview against a thread with a run
|
|
@@ -84,8 +108,11 @@ their configured base values.
|
|
|
84
108
|
|
|
85
109
|
Built-in adapters live in `src/adapters/` and are discovered at runtime. Additional `.mjs`,
|
|
86
110
|
`.js`, or `.cjs` adapters can be placed in `~/.cli-relay/adapters/`; an adapter with the same
|
|
87
|
-
`name` as a built-in replaces it.
|
|
88
|
-
`
|
|
111
|
+
`name` as a built-in replaces it. Note that `.js` files load as CommonJS unless a
|
|
112
|
+
`~/.cli-relay/package.json` sets `"type": "module"` — write ESM adapters as `.mjs` (an
|
|
113
|
+
ESM-syntax `.js` file fails to load and is skipped with a warning). Each adapter provides
|
|
114
|
+
`fresh`, optional `resume`, `env`, `parse`, optional `checkCompaction`, and optional
|
|
115
|
+
`binaryCandidates` (an ordered list of
|
|
89
116
|
binary names `doctor` tries — most adapters only need one, but a backend that ships under
|
|
90
117
|
more than one binary name can list several). Housekeeping commands are dispatched before
|
|
91
118
|
adapter discovery, so they remain available if an adapter cannot be loaded. A malformed
|
|
@@ -137,9 +164,13 @@ Backs up and restores your actual `~/.cli-relay/sessions.json` around the run, s
|
|
|
137
164
|
time. Covers: list/reset, fresh→resume context retention (agy), the circuit breaker's actual
|
|
138
165
|
3-strikes trip (live-fired against codex with a bad id, not just traced), SIGINT mid-run
|
|
139
166
|
cleanup, and SIGINT while genuinely pre-spawn (lock held elsewhere — must abort immediately
|
|
140
|
-
without spawning).
|
|
141
|
-
|
|
142
|
-
|
|
167
|
+
without spawning). 56/56 passing as of the last run. Since the 2026-09-02 GLM-5.3 fixes the
|
|
168
|
+
tail sections also cover, hermetically (a fake `agy` shimmed onto PATH, direct lock-module
|
|
169
|
+
harnesses, and PATH-stubbed binaries — no live backend needed): the reset-then-recreate
|
|
170
|
+
refusal, the cancelled-resume circuit-breaker behavior, lock release ownership /
|
|
171
|
+
exclusive holder write / pid reuse / stale reclaim, and `doctor`'s exit-code gate. Does not
|
|
172
|
+
exercise `claude-code` (real billing per call) or `command-code` resume (disabled);
|
|
173
|
+
`--dry-run` is still verified manually against the real backends whenever it changes (see
|
|
143
174
|
the 2026-09-01 Review history entries below for what that's caught), not by an automated
|
|
144
175
|
case in this file yet.
|
|
145
176
|
|
|
@@ -238,6 +269,45 @@ correctly flagged its own uncertainty (its sandbox blocked `git` access) rather
|
|
|
238
269
|
asserting it, which is why it was checked rather than trusted or dismissed outright. Full
|
|
239
270
|
brief in `docs/cli-continues-cherrypick-brief.md`.
|
|
240
271
|
|
|
272
|
+
**GLM-5.3 adversarial audit fixes (2026-09-02).** A fresh GLM-5.3 session audited the
|
|
273
|
+
merged result against `main` at 1.0.2 (full brief in `docs/glm53-fix-brief.md`); every
|
|
274
|
+
finding was re-verified against the actual source before fixing, in this project's
|
|
275
|
+
usual audit → fix → adversarial re-check rhythm. Three must-fix races: (1)
|
|
276
|
+
`reset`-then-recreate mid-run corrupted the session map — if thread A was mid-run and
|
|
277
|
+
someone ran `cli-relay reset t` then a quick recreate that completed first, A's critical
|
|
278
|
+
section 2 would find the *replacement* session and unconditionally overwrite its
|
|
279
|
+
`native_session_id`, cross-wiring a codex id onto an agy-backend thread (or vice versa),
|
|
280
|
+
or silently discarding the newer id for an older one — CS2 and the fresh-failure branch
|
|
281
|
+
now verify the map entry is still the *same session instance* CS1 snapshotted (backend +
|
|
282
|
+
pre-run native id + CS1's run-start timestamp) and warn-and-refuse on mismatch, the same
|
|
283
|
+
pattern the thread-deleted race already used; (2) lock release never verified the
|
|
284
|
+
releaser still owned the lock — a holder suspended past `LOCK_STALE_MS` (laptop sleep,
|
|
285
|
+
VM pause) could wake and `rm` its *successor's* lock directory, admitting a third
|
|
286
|
+
entrant — release now mirrors `reclaim()`'s rename-verify-restore pattern; (3) the
|
|
287
|
+
`mkdir` → `holder.json` write gap wasn't safely closed — a suspension longer than
|
|
288
|
+
`LOCK_TIMEOUT_MS` between the two calls let the original holder's plain write clobber
|
|
289
|
+
the successor's `holder.json`, producing two holders — the write is now an exclusive
|
|
290
|
+
(`'wx'`) create whose EEXIST falls back to the waiter path, with the mkdir-EEXIST and
|
|
291
|
+
write-EEXIST error paths split. Lower severity: a Ctrl-C'd (or timeout-killed) resume no
|
|
292
|
+
longer counts toward the 3-strikes circuit breaker — a cancelled run is neither success
|
|
293
|
+
nor failure, so the counter is left untouched (three interrupted resumes on a healthy
|
|
294
|
+
thread previously auto-unconfirmed it); a stale holder record bearing the *next
|
|
295
|
+
invocation's own reused pid* is now reclaimed instead of being waited on until the lock
|
|
296
|
+
times out (pid reuse by an unrelated process still self-heals via the timestamp path);
|
|
297
|
+
`tests/smoke.sh`'s `rm -f` on the lock directory — a silent no-op on directories — is
|
|
298
|
+
now `rm -rf`; `doctor` exits 1 only when no backend at all is usable (a partial install —
|
|
299
|
+
the README-documented normal setup — still exits 0) so it works as a CI gate; and pin
|
|
300
|
+
text containing the literal `[END PINNED FACTS]` or a carriage return is rejected, so a
|
|
301
|
+
pin can no longer forge the pinned-block boundary.
|
|
302
|
+
Deliberately left as-is, with reasoning: the documented `--dry-run` token-stripping
|
|
303
|
+
limitation (any fix changes flag-position CLI parsing behavior, and the audience is
|
|
304
|
+
agents, not free-text discussion of flags); `.js` user adapters still load, now
|
|
305
|
+
explicitly documented as CommonJS unless a `~/.cli-relay/package.json` says otherwise
|
|
306
|
+
(chosen over dropping `.js` support — less disruptive for existing working CommonJS
|
|
307
|
+
adapters); and the Windows process-group-kill gap is now a documented known gap rather
|
|
308
|
+
than a guess-fix. The smoke suite grew hermetic fake-backend, lock-module, and doctor
|
|
309
|
+
harness sections alongside the live-backend cases.
|
|
310
|
+
|
|
241
311
|
## Field notes from real use
|
|
242
312
|
|
|
243
313
|
First real-world use (2026-08-17, an audit task run across all three model lanes in
|
|
@@ -360,6 +430,13 @@ guessing at intent, the same trap this whole project has avoided everywhere else
|
|
|
360
430
|
finished), so the thread doesn't self-heal via lock staleness — it stays stuck until
|
|
361
431
|
`LOCK_STALE_MS` (~21 min) passes on the *next* invocation against that thread. Narrow
|
|
362
432
|
window, real gap; not closed here.
|
|
433
|
+
- **Windows process-group cleanup is POSIX-only (documented 2026-09-02, not fixed).**
|
|
434
|
+
`package.json` declares no `os` restriction, but the wrapper's child termination —
|
|
435
|
+
`process.kill(-pgid, ...)` for Ctrl-C and the spawn timeout — no-ops on Windows, so a
|
|
436
|
+
backend child there would survive a router-initiated kill. A real fix needs
|
|
437
|
+
`taskkill /T /F` and a Windows box to verify against; this repo's test, dev, and
|
|
438
|
+
publish history is entirely macOS/Linux, so it's flagged here rather than
|
|
439
|
+
guess-fixed.
|
|
363
440
|
- `doctor`'s `which`/`where` child processes aren't tracked by the SIGINT handler — a Ctrl-C
|
|
364
441
|
during `doctor` reports "nothing spawned yet" even though those children are briefly alive.
|
|
365
442
|
Low severity (`which` exits in milliseconds).
|
package/cli-relay.mjs
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Exit codes:
|
|
15
15
|
* 0 success
|
|
16
|
-
* 1 general error or backend spawn failure
|
|
16
|
+
* 1 general error or backend spawn failure (also `doctor`, when no backend at
|
|
17
|
+
* all is usable — a partial install is the normal setup and still exits 0)
|
|
17
18
|
* 2 usage error
|
|
18
19
|
* 3 backend produced an id/exit-0-shaped result but no usable answer
|
|
19
20
|
*/
|
|
@@ -200,6 +201,20 @@ function assertNotInFlight(session, thread) {
|
|
|
200
201
|
);
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
// CS1 snapshots the exact session instance it marks running. CS2 (and the fresh-failure
|
|
205
|
+
// branch below) must verify the map entry is still that same instance before recording
|
|
206
|
+
// an outcome: a `reset` mid-run followed by a recreate on the same thread name leaves a
|
|
207
|
+
// *different* session under that name, and recording onto it would cross-wire a native
|
|
208
|
+
// session id across backends (a codex id on an agy-backend thread, or vice versa) or
|
|
209
|
+
// silently discard the replacement's newer id (found in the GLM-5.3 audit). Backend plus
|
|
210
|
+
// the pre-run native id plus CS1's own run-start timestamp identify the instance; on
|
|
211
|
+
// mismatch, warn and refuse to record — the same pattern the thread-deleted case uses.
|
|
212
|
+
function isSameSessionInstance(session, record) {
|
|
213
|
+
return session.backend === record.backend &&
|
|
214
|
+
session.native_session_id === record.native_session_id &&
|
|
215
|
+
session.run_started_iso === record.run_started_iso;
|
|
216
|
+
}
|
|
217
|
+
|
|
203
218
|
async function main() {
|
|
204
219
|
const cliArgs = process.argv.slice(2);
|
|
205
220
|
|
|
@@ -210,8 +225,8 @@ async function main() {
|
|
|
210
225
|
const { loadAdapters } = await import('./src/adapter-loader.mjs');
|
|
211
226
|
const adapters = await loadAdapters();
|
|
212
227
|
if (cliArgs[0] === 'doctor') {
|
|
213
|
-
await cmdDoctor(adapters);
|
|
214
|
-
process.exit(0);
|
|
228
|
+
const anyBackendUsable = await cmdDoctor(adapters);
|
|
229
|
+
process.exit(anyBackendUsable ? 0 : 1);
|
|
215
230
|
}
|
|
216
231
|
|
|
217
232
|
const dryRun = cliArgs.includes('--dry-run') || cliArgs.includes('--print-command');
|
|
@@ -331,6 +346,17 @@ async function main() {
|
|
|
331
346
|
);
|
|
332
347
|
return;
|
|
333
348
|
}
|
|
349
|
+
if (!isSameSessionInstance(session, record)) {
|
|
350
|
+
// Reset-then-recreate mid-run: the map entry is now a different session
|
|
351
|
+
// instance (possibly on a different backend). Don't cross-wire this run's
|
|
352
|
+
// outcome onto it.
|
|
353
|
+
console.error(
|
|
354
|
+
`warning: thread "${thread}" in ${MAP_PATH} is no longer the session this run ` +
|
|
355
|
+
`started from (it was reset or replaced while this run was in flight) — ` +
|
|
356
|
+
'outcome not recorded.',
|
|
357
|
+
);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
334
360
|
session.status = 'ready';
|
|
335
361
|
session.last_run_iso = new Date().toISOString();
|
|
336
362
|
session.last_exit_code = code;
|
|
@@ -369,22 +395,40 @@ async function main() {
|
|
|
369
395
|
);
|
|
370
396
|
return;
|
|
371
397
|
}
|
|
398
|
+
if (!isSameSessionInstance(session, record)) {
|
|
399
|
+
// Reset-then-recreate mid-run: the map entry is now a different session
|
|
400
|
+
// instance (possibly on a different backend). Don't cross-wire this run's
|
|
401
|
+
// native id onto it or discard its newer id in favor of this run's.
|
|
402
|
+
console.error(
|
|
403
|
+
`warning: thread "${thread}" in ${MAP_PATH} is no longer the session this run ` +
|
|
404
|
+
`started from (it was reset or replaced while this run was in flight) — outcome` +
|
|
405
|
+
`${newId ? ` (including native id ${newId})` : ''} not recorded.`,
|
|
406
|
+
);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
372
409
|
if (newId) {
|
|
373
410
|
session.native_session_id = newId;
|
|
374
411
|
session.confirmed = true;
|
|
375
412
|
session.consecutive_resume_failures = 0;
|
|
376
413
|
}
|
|
377
414
|
if (mode === 'resume') {
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
415
|
+
if (!cancelled) {
|
|
416
|
+
if (parsed.answer) {
|
|
417
|
+
session.consecutive_resume_failures = 0;
|
|
418
|
+
} else {
|
|
419
|
+
session.consecutive_resume_failures = (session.consecutive_resume_failures ?? 0) + 1;
|
|
420
|
+
if (session.confirmed &&
|
|
421
|
+
session.consecutive_resume_failures >= RESUME_FAILURE_THRESHOLD) {
|
|
422
|
+
session.confirmed = false;
|
|
423
|
+
autoUnconfirmed = true;
|
|
424
|
+
}
|
|
386
425
|
}
|
|
387
426
|
}
|
|
427
|
+
// A run the wrapper cancelled (Ctrl-C, SIGTERM, or the spawn timeout) says
|
|
428
|
+
// nothing about the backend's resume health — neither success nor failure.
|
|
429
|
+
// The failure counter is deliberately left untouched: three interrupted
|
|
430
|
+
// resumes on a perfectly healthy thread must not auto-unconfirm it (found
|
|
431
|
+
// in the GLM-5.3 audit).
|
|
388
432
|
resumeFailureCount = session.consecutive_resume_failures;
|
|
389
433
|
}
|
|
390
434
|
session.status = 'ready';
|
package/package.json
CHANGED
package/src/commands/doctor.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { EXPECTED_BACKENDS } from '../adapter-loader.mjs';
|
|
3
|
+
|
|
4
|
+
const REQUIRED_BACKEND_SET = new Set(EXPECTED_BACKENDS);
|
|
2
5
|
|
|
3
6
|
export function realWhichCheck(binary) {
|
|
4
7
|
return new Promise((resolve) => {
|
|
@@ -36,9 +39,11 @@ export async function cmdDoctor(adapters, isAvailable = realWhichCheck) {
|
|
|
36
39
|
return resolved;
|
|
37
40
|
}));
|
|
38
41
|
|
|
42
|
+
let requiredAvailable = 0;
|
|
39
43
|
for (let index = 0; index < results.length; index += 1) {
|
|
40
44
|
const result = results[index];
|
|
41
45
|
const check = checks[index];
|
|
46
|
+
const isRequired = REQUIRED_BACKEND_SET.has(check.adapter.name);
|
|
42
47
|
const tried = check.attempted.join(', ') || 'none';
|
|
43
48
|
if (result.status === 'rejected') {
|
|
44
49
|
console.log(
|
|
@@ -52,9 +57,17 @@ export async function cmdDoctor(adapters, isAvailable = realWhichCheck) {
|
|
|
52
57
|
console.log(
|
|
53
58
|
`${check.adapter.name}: available (found; tried: ${tried}; resolved: ${resolved})`,
|
|
54
59
|
);
|
|
60
|
+
if (isRequired) requiredAvailable += 1;
|
|
55
61
|
} else {
|
|
56
62
|
const hint = check.adapter.installHint ? ` — install: ${check.adapter.installHint}` : '';
|
|
57
63
|
console.log(`${check.adapter.name}: unavailable (not found; tried: ${tried})${hint}`);
|
|
58
64
|
}
|
|
59
65
|
}
|
|
66
|
+
|
|
67
|
+
// doctor doubles as a machine-setup gate: the caller (main) exits 1 only when NO backend
|
|
68
|
+
// at all is usable — a genuinely broken install with nothing working. A partial install
|
|
69
|
+
// (1-3 of the four built-in backends present) is the documented normal setup (README,
|
|
70
|
+
// Prerequisites: "you don't need all four") and passes the gate. Custom user adapters are
|
|
71
|
+
// reported in the output above but never affect it — they're optional by definition.
|
|
72
|
+
return requiredAvailable > 0;
|
|
60
73
|
}
|
package/src/core/lock.mjs
CHANGED
|
@@ -75,49 +75,129 @@ function reclaim(expectedHolderRaw) {
|
|
|
75
75
|
|
|
76
76
|
export async function withLock(fn) {
|
|
77
77
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
78
|
+
let myHolderRaw = null;
|
|
78
79
|
for (;;) {
|
|
79
80
|
try {
|
|
80
81
|
mkdirSync(LOCK_PATH);
|
|
81
|
-
writeFileSync(
|
|
82
|
-
join(LOCK_PATH, LOCK_HOLDER_FILE),
|
|
83
|
-
JSON.stringify({ pid: process.pid, ts: Date.now() }),
|
|
84
|
-
);
|
|
85
|
-
break;
|
|
86
82
|
} catch (error) {
|
|
87
83
|
if (error.code !== 'EEXIST') throw error;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
const holder = JSON.parse(holderRaw);
|
|
93
|
-
eligible = Date.now() - holder.ts > LOCK_STALE_MS || !isPidAlive(holder.pid);
|
|
94
|
-
} catch {
|
|
95
|
-
eligible = true; // holder.json exists but isn't valid JSON — not a state any live holder writes
|
|
96
|
-
}
|
|
97
|
-
} else {
|
|
98
|
-
// Missing holder.json while the lock dir exists: either mid-write (a real holder's
|
|
99
|
-
// mkdir+writeFileSync are back-to-back synchronous, resolving in microseconds) or
|
|
100
|
-
// the holder died in that exact window and never will. Use the lock directory's own
|
|
101
|
-
// mtime — an objective fact any process can check on the same physical directory —
|
|
102
|
-
// rather than this waiter's own elapsed wait time, which can't tell "the original
|
|
103
|
-
// holder died" apart from "a brand-new holder's mkdir just landed."
|
|
104
|
-
try {
|
|
105
|
-
eligible = Date.now() - statSync(LOCK_PATH).mtimeMs > LOCK_TIMEOUT_MS;
|
|
106
|
-
} catch {
|
|
107
|
-
eligible = false; // the directory itself vanished already — nothing here to reclaim
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
if (eligible && reclaim(holderRaw)) continue;
|
|
111
|
-
if (Date.now() > deadline) {
|
|
112
|
-
throw new Error(`another cli-relay invocation holds the lock (${LOCK_PATH}) — not waiting forever`);
|
|
113
|
-
}
|
|
114
|
-
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
|
|
84
|
+
// Someone else's lock directory exists — evaluate it as a waiter below.
|
|
85
|
+
await waitForLockSlot(deadline);
|
|
86
|
+
continue;
|
|
115
87
|
}
|
|
88
|
+
|
|
89
|
+
// The directory is ours and empty. Write holder.json with an EXCLUSIVE create
|
|
90
|
+
// ('wx'): if a waiter reclaimed this still-empty directory while we were
|
|
91
|
+
// suspended between mkdir and this write (a suspension longer than
|
|
92
|
+
// LOCK_TIMEOUT_MS — laptop sleep, VM pause), the successor's holder.json
|
|
93
|
+
// already exists and a plain write would silently clobber it — two holders at
|
|
94
|
+
// once (found in the GLM-5.3 audit). EEXIST throws loudly instead, and the
|
|
95
|
+
// loser falls back to the waiter path below: the successor is the rightful
|
|
96
|
+
// holder now.
|
|
97
|
+
myHolderRaw = JSON.stringify({ pid: process.pid, ts: Date.now() });
|
|
98
|
+
try {
|
|
99
|
+
writeFileSync(join(LOCK_PATH, LOCK_HOLDER_FILE), myHolderRaw, { flag: 'wx' });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error.code !== 'EEXIST') throw error;
|
|
102
|
+
// Our just-created directory was reclaimed mid-gap and now holds a
|
|
103
|
+
// successor's holder.json — evaluate the lock as a waiter, same as the
|
|
104
|
+
// mkdir-EEXIST case, but for a distinct reason (we briefly held the
|
|
105
|
+
// directory; the EEXIST came from the write, not the mkdir).
|
|
106
|
+
await waitForLockSlot(deadline);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
116
110
|
}
|
|
117
111
|
|
|
118
112
|
try {
|
|
119
113
|
return await fn();
|
|
120
114
|
} finally {
|
|
121
|
-
|
|
115
|
+
releaseLock(myHolderRaw);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The waiter half of acquisition, shared by both EEXIST paths above: the lock
|
|
120
|
+
// directory exists but isn't ours — either created by someone else, or created by
|
|
121
|
+
// us and then legitimately reclaimed out from under our suspended holder.json
|
|
122
|
+
// write. Judge the current holder, reclaim it if eligible, and otherwise sleep
|
|
123
|
+
// until the deadline.
|
|
124
|
+
async function waitForLockSlot(deadline) {
|
|
125
|
+
const holderRaw = readHolderRaw();
|
|
126
|
+
let eligible = false;
|
|
127
|
+
if (holderRaw !== null) {
|
|
128
|
+
try {
|
|
129
|
+
const holder = JSON.parse(holderRaw);
|
|
130
|
+
// A holder record bearing this process's own pid cannot be a lock we hold:
|
|
131
|
+
// this path only runs before this call acquires, and an earlier withLock in
|
|
132
|
+
// this process has always released first (cli-relay never overlaps them). It
|
|
133
|
+
// is either an abandoned leftover of our own failed release or — the case
|
|
134
|
+
// that matters — a dead holder whose pid was reused by this very invocation,
|
|
135
|
+
// which would otherwise see *itself* as a live holder and fail spuriously
|
|
136
|
+
// after LOCK_TIMEOUT_MS (found in the GLM-5.3 audit; the mirror-image
|
|
137
|
+
// false-positive, pid reuse by an unrelated process, still self-heals via
|
|
138
|
+
// the timestamp path above).
|
|
139
|
+
eligible = Date.now() - holder.ts > LOCK_STALE_MS ||
|
|
140
|
+
!isPidAlive(holder.pid) ||
|
|
141
|
+
holder.pid === process.pid;
|
|
142
|
+
} catch {
|
|
143
|
+
eligible = true; // holder.json exists but isn't valid JSON — not a state any live holder writes
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
// Missing holder.json while the lock dir exists: either mid-write (a real
|
|
147
|
+
// holder's mkdir+writeFileSync are back-to-back synchronous, resolving in
|
|
148
|
+
// microseconds, and the exclusive write now fails loudly rather than
|
|
149
|
+
// clobbering) or the holder died in that exact window and never will. Use the
|
|
150
|
+
// lock directory's own mtime — an objective fact any process can check on the
|
|
151
|
+
// same physical directory — rather than this waiter's own elapsed wait time,
|
|
152
|
+
// which can't tell "the original holder died" apart from "a brand-new
|
|
153
|
+
// holder's mkdir just landed."
|
|
154
|
+
try {
|
|
155
|
+
eligible = Date.now() - statSync(LOCK_PATH).mtimeMs > LOCK_TIMEOUT_MS;
|
|
156
|
+
} catch {
|
|
157
|
+
eligible = false; // the directory itself vanished already — nothing here to reclaim
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (eligible && reclaim(holderRaw)) return;
|
|
161
|
+
if (Date.now() > deadline) {
|
|
162
|
+
throw new Error(`another cli-relay invocation holds the lock (${LOCK_PATH}) — not waiting forever`);
|
|
163
|
+
}
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Releasing by unconditionally rmSync-ing LOCK_PATH never verifies the releaser
|
|
168
|
+
// still owns the lock. If a holder is suspended past LOCK_STALE_MS (laptop sleep,
|
|
169
|
+
// VM pause, or a clock adjustment that makes its timestamp look stale), a waiter
|
|
170
|
+
// legitimately reclaims and becomes the new holder; when the original holder wakes
|
|
171
|
+
// and finishes, an unconditional delete destroys its successor's lock and admits a
|
|
172
|
+
// third entrant (found in the GLM-5.3 audit). Mirror reclaim()'s own pattern:
|
|
173
|
+
// rename to a private path first (atomic — at most one renamer of a given source
|
|
174
|
+
// path can win), verify the captured holder.json is still this process's own
|
|
175
|
+
// write, and only then rm; if it isn't ours, put it back for the rightful holder
|
|
176
|
+
// rather than destroying it.
|
|
177
|
+
function releaseLock(myHolderRaw) {
|
|
178
|
+
const claim = `${LOCK_PATH}.release.${process.pid}.${Date.now()}`;
|
|
179
|
+
try {
|
|
180
|
+
renameSync(LOCK_PATH, claim);
|
|
181
|
+
} catch {
|
|
182
|
+
return; // nothing at LOCK_PATH (or a real error) — nothing to either verify or destroy
|
|
183
|
+
}
|
|
184
|
+
let capturedHolderRaw;
|
|
185
|
+
try {
|
|
186
|
+
capturedHolderRaw = readFileSync(join(claim, LOCK_HOLDER_FILE), 'utf8');
|
|
187
|
+
} catch {
|
|
188
|
+
capturedHolderRaw = null;
|
|
189
|
+
}
|
|
190
|
+
if (capturedHolderRaw === myHolderRaw) {
|
|
191
|
+
rmSync(claim, { recursive: true, force: true });
|
|
192
|
+
} else {
|
|
193
|
+
// Not our write anymore — a waiter reclaimed while we were suspended and this
|
|
194
|
+
// is its lock. Restore it; if LOCK_PATH was recreated in the meantime by yet
|
|
195
|
+
// another party there is nowhere safe to put it back — drop it rather than
|
|
196
|
+
// loop forever, the same compounding-race reasoning as reclaim().
|
|
197
|
+
try {
|
|
198
|
+
renameSync(claim, LOCK_PATH);
|
|
199
|
+
} catch {
|
|
200
|
+
rmSync(claim, { recursive: true, force: true });
|
|
201
|
+
}
|
|
122
202
|
}
|
|
123
203
|
}
|
package/src/core/pins.mjs
CHANGED
|
@@ -2,9 +2,13 @@ import { MAX_PIN_LENGTH } from '../config.mjs';
|
|
|
2
2
|
|
|
3
3
|
export function validatePinText(factText) {
|
|
4
4
|
if (!factText || !factText.trim()) return 'fact text must not be empty or whitespace-only';
|
|
5
|
-
if (factText.includes('\n')) {
|
|
5
|
+
if (factText.includes('\n') || factText.includes('\r')) {
|
|
6
6
|
return 'fact text must be a single line — multiline facts risk breaking the pinned-block delimiter';
|
|
7
7
|
}
|
|
8
|
+
if (factText.includes('[END PINNED FACTS]')) {
|
|
9
|
+
return 'fact text must not contain the literal "[END PINNED FACTS]" — a pin must not ' +
|
|
10
|
+
'be able to forge the pinned-block boundary (found in the GLM-5.3 audit)';
|
|
11
|
+
}
|
|
8
12
|
if (factText.length > MAX_PIN_LENGTH) {
|
|
9
13
|
return `fact text is ${factText.length} chars, over the ${MAX_PIN_LENGTH}-char limit — ` +
|
|
10
14
|
'keep pins short and curated, not a transcript';
|