@junghanacs/entwurf 0.22.0 → 0.23.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/AGENTS.md +4 -1
- package/BASELINE.md +1 -0
- package/CHANGELOG.md +514 -0
- package/DELIVERY.md +10 -2
- package/README.md +43 -4
- package/VERIFY.md +47 -0
- package/docs/acp-backend-rail.md +96 -17
- package/docs/setup-clean-host.md +11 -1
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -41
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +9 -8
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +4 -4
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
- package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
- package/pi-extensions/lib/entwurf-facts.ts +14 -1
- package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
- package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
- package/pi-extensions/lib/entwurf-self-address.ts +33 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +493 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
- package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
- package/pi-extensions/lib/herdr-placement.ts +276 -0
- package/pi-extensions/lib/mux-fresh-call.ts +95 -301
- package/run.sh +588 -204
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-codex-birth-hook.ts +187 -0
- package/scripts/check-entwurf-control-rpc.ts +138 -0
- package/scripts/check-entwurf-fact-provider.ts +4 -2
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +17 -4
- package/scripts/check-herdr-activation.ts +1293 -0
- package/scripts/check-herdr-fresh-call.ts +1250 -0
- package/scripts/check-herdr-placement.ts +383 -0
- package/scripts/check-herdr-plugin-build.ts +842 -0
- package/scripts/check-herdr-plugin-profile.ts +371 -0
- package/scripts/check-herdr-plugin.ts +523 -0
- package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
- package/scripts/check-herdr-sandbox.ts +456 -0
- package/scripts/check-herdr-supply.ts +184 -0
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-pi-launch.ts +358 -0
- package/scripts/check-release-gate-outcomes.ts +19 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-birth-doctor.sh +159 -16
- package/scripts/codex-birth-install.sh +178 -72
- package/scripts/codex-birth-uninstall.sh +162 -7
- package/scripts/fixtures/herdr-supply.json +29 -0
- package/scripts/herdr-activation.mjs +536 -0
- package/scripts/herdr-plugin-activate.mjs +270 -0
- package/scripts/herdr-plugin-deactivate.mjs +193 -0
- package/scripts/herdr-runtime.mjs +1203 -0
- package/scripts/install-herdr-ci.sh +96 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
- package/scripts/lib/tree-digest.ts +97 -0
- package/scripts/meta-bridge-install.sh +19 -2
- package/scripts/meta-bridge-state.py +167 -11
- package/scripts/mutants/codex-caller-seat.json +6 -8
- package/scripts/mutants/codex-native.json +195 -29
- package/scripts/mutants/control-socket-disconnect.json +57 -0
- package/scripts/mutants/fresh-call-dispatch.json +154 -0
- package/scripts/mutants/herdr-activation.json +305 -0
- package/scripts/mutants/herdr-fresh-call.json +448 -0
- package/scripts/mutants/herdr-placement.json +161 -0
- package/scripts/mutants/herdr-plugin-build.json +160 -0
- package/scripts/mutants/herdr-plugin-profile.json +202 -0
- package/scripts/mutants/herdr-plugin.json +133 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
- package/scripts/mutants/herdr-supply.json +133 -0
- package/scripts/mutants/meta-hook-session-switch.json +2 -2
- package/scripts/mutants/mux-fresh-call.json +104 -17
- package/scripts/mutants/mux-launcher-fence.json +13 -0
- package/scripts/mutants/omp-fresh.json +4 -4
- package/scripts/mutants/peer-facts.json +98 -0
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/peer-facts.ts +120 -0
- package/scripts/register-pi-package.py +8 -0
- package/scripts/register-pi-provider.py +88 -12
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-codex-birth.sh +281 -12
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-herdr-raw-install-live.sh +849 -0
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -385
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- package/scripts/check-socket-discovery.ts +0 -401
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-herdr-fresh-call — deterministic gate for the #116 herdr launch rail core
|
|
3
|
+
* (`pi-extensions/lib/herdr-fresh-call.ts`).
|
|
4
|
+
*
|
|
5
|
+
* SCOPE DISCIPLINE, same as its S1 sibling: only what is decidable WITHOUT a herdr binary —
|
|
6
|
+
* argv grammar, the option-G encoding, response parsing, the reject vocabulary, the
|
|
7
|
+
* conditional-close matrix, the receipt shape, and the import fence that keeps the two rails
|
|
8
|
+
* separately deletable. There is no fake herdr executable and no fake server here
|
|
9
|
+
* (`scripts/check-mux-placement.ts:6-9` refuses a fake tmux for the reason that applies twice
|
|
10
|
+
* as hard to a stand-in server: it would agree with whatever we believed the day we wrote it).
|
|
11
|
+
* The real-binary half is an isolated private server, and that is c2.
|
|
12
|
+
*
|
|
13
|
+
* Every JSON literal below is a payload recorded from herdr 0.9.0 — the pane/tab replies on
|
|
14
|
+
* 2026-09-15, the agent_started reply on 2026-09-14. Field sets and shapes are verbatim. Where
|
|
15
|
+
* two replies have to describe the SAME sibling, the coordinates of the start recording are
|
|
16
|
+
* carried into the tab recording, and that splice is stated here rather than implied: a fixture
|
|
17
|
+
* whose two halves named different panes could not test the binding between them at all.
|
|
18
|
+
*
|
|
19
|
+
* Each claim carries its QK token on exactly ONE assertion.
|
|
20
|
+
*
|
|
21
|
+
* HFC-RAIL-IMPORT-FENCE the module imports no mux and no entwurf module
|
|
22
|
+
* HFC-PILOT-CLOSED a non-pilot backend is a pre-mutation named reject, with no
|
|
23
|
+
* tmux fallback anywhere in the module
|
|
24
|
+
* HFC-TMUX-PLACEMENT-REFUSED a tmux seat input inside herdr is refused by name, never ignored
|
|
25
|
+
* HFC-ENCODE-ROUNDTRIP the encoded argv decodes back to the exact prompt, bytes intact
|
|
26
|
+
* HFC-ENCODE-NO-CONTROL the encoded argv carries zero Unicode Cc — including the C1
|
|
27
|
+
* block and DEL, which JSON.stringify leaves literal
|
|
28
|
+
* HFC-ENCODE-MAX-TASK a maximum public task encodes and stays Cc-free
|
|
29
|
+
* HFC-PREMUTATION-ONLY every refusal happens before the first herdr call
|
|
30
|
+
* HFC-CALLER-WORKSPACE the caller's workspace comes from herdr's own answer about the
|
|
31
|
+
* caller's own pane, never from parsing a pane id, and an
|
|
32
|
+
* unusable answer refuses instead of defaulting
|
|
33
|
+
* HFC-CALLER-PANE-BOUND that answer must be ABOUT the pane we asked about — a readable
|
|
34
|
+
* reply describing another pane is refused by its own name
|
|
35
|
+
* HFC-TAB-HALVES-AGREE the tab-create reply's two halves name the same tab and the
|
|
36
|
+
* same workspace, and absent is disagreement, not optionality
|
|
37
|
+
* HFC-CREATE-OUTCOME-HONEST a failed tab create says whether anything EXISTS, and never
|
|
38
|
+
* spells "nothing happened" as an unreclaimed orphan
|
|
39
|
+
* HFC-TAB-ARGV the one placement policy — a new tab in the caller's workspace,
|
|
40
|
+
* focus left alone, cwd literal, identity scrubbed explicitly
|
|
41
|
+
* HFC-START-ARGV backend argv rides after `--`, under the requested kind
|
|
42
|
+
* HFC-AGENT-NAME-OPAQUE the herdr agent name is derived from the nonce and is valid
|
|
43
|
+
* under herdr's own name rule
|
|
44
|
+
* HFC-PARSE-STRICT a payload that is not the measured shape is declined, never
|
|
45
|
+
* half-believed
|
|
46
|
+
* HFC-PANE-ID-OPAQUE a `pA`-style pane id survives untouched — no decimal parser
|
|
47
|
+
* HFC-RECEIPT-NO-ADDRESS the receipt carries no garden id, native session id, screen
|
|
48
|
+
* text or herdr view judgement
|
|
49
|
+
* HFC-CLOSE-WITHIN-GENERATION a close needs id AND terminal match AND no agent session
|
|
50
|
+
* HFC-CLOSE-REFUSES-OCCUPIED a pane herdr says an AGENT is in is never closed, whether or not
|
|
51
|
+
* anybody has reported that agent's session id
|
|
52
|
+
* HFC-ORPHAN-NAMED every failed reclaim names its reason instead of going quiet
|
|
53
|
+
* HFC-INPUT-PARITY the caller-facing input contract is the SHARED one, word for word
|
|
54
|
+
* HFC-CWD-EMPTY-IS-OMITTED `cwd: ""` means "no cwd", as it does on the other rail
|
|
55
|
+
* HFC-CWD-HASH-IS-DATA a `#` in the start directory is DATA here — the tmux rail's
|
|
56
|
+
* format-token refusal is not imported, because its reason is
|
|
57
|
+
* false on a rail with no format expansion
|
|
58
|
+
* HFC-TAB-OCCUPIED a new tab whose initial pane holds an agent does not get started into
|
|
59
|
+
* HFC-START-PANE-BINDING a start that reports a different pane/terminal is a named failure
|
|
60
|
+
* HFC-WITNESS-SETTLED-BY-READ a start whose reply carries no agent session is SETTLED by
|
|
61
|
+
* bounded read-only re-reads of the EXACT agent name, never by a
|
|
62
|
+
* listing and never by a keystroke
|
|
63
|
+
* HFC-WITNESS-ABSENCE-KEEPS the settle window expiring is a SUCCESSFUL launch carrying an
|
|
64
|
+
* `unavailable` witness — it closes nothing. `[측정 oracle
|
|
65
|
+
* 2026-09-18]` the shape this replaces closed a pane holding a live
|
|
66
|
+
* Claude Code child 0.585s after that child wrote its birth record
|
|
67
|
+
* HFC-WITNESS-DRIFT-VANISHED a readable reply about a DIFFERENT agent is the one failure this
|
|
68
|
+
* stage can name, and only it may reach the reclaim
|
|
69
|
+
* HFC-START-ARGV-FIDELITY the echoed argv must be exactly what we asked herdr to compose
|
|
70
|
+
* HERDR-RUNNER-NONBLOCKING a herdr call in flight does NOT hold the caller's event loop —
|
|
71
|
+
* proved against a REAL child process while a REAL socket round
|
|
72
|
+
* trip and a timer are serviced in the same process
|
|
73
|
+
* HERDR-RUNNER-TIMEOUT-KILLS the bound kills the child and settles once, and the child is
|
|
74
|
+
* observed dead afterwards
|
|
75
|
+
* HERDR-RUNNER-OUTPUT-BOUNDED a runaway child is cut off at the cap instead of being buffered
|
|
76
|
+
* without limit
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
import assert from "node:assert/strict";
|
|
80
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
81
|
+
import * as fs from "node:fs";
|
|
82
|
+
import * as net from "node:net";
|
|
83
|
+
import * as os from "node:os";
|
|
84
|
+
import * as path from "node:path";
|
|
85
|
+
import { fileURLToPath } from "node:url";
|
|
86
|
+
import { composeFreshCallFraming } from "../pi-extensions/lib/fresh-call-composition.ts";
|
|
87
|
+
import {
|
|
88
|
+
argvMatchesRequest,
|
|
89
|
+
buildHerdrAgentGetArgs,
|
|
90
|
+
buildHerdrAgentStartArgs,
|
|
91
|
+
buildHerdrPaneCloseArgs,
|
|
92
|
+
buildHerdrPaneGetArgs,
|
|
93
|
+
buildHerdrTabCreateArgs,
|
|
94
|
+
classifyHerdrCwd,
|
|
95
|
+
containsControlChar,
|
|
96
|
+
createHerdrRunner,
|
|
97
|
+
decideConditionalClose,
|
|
98
|
+
encodeBirthPrompt,
|
|
99
|
+
HERDR_AGENT_SESSION_POLL_MS,
|
|
100
|
+
HERDR_AGENT_SESSION_SETTLE_MS,
|
|
101
|
+
HERDR_CLI_TIMEOUT_MS,
|
|
102
|
+
HERDR_FRESH_CALL_BACKENDS,
|
|
103
|
+
HERDR_FRESH_CALL_OPENING_LINE,
|
|
104
|
+
HERDR_MAX_OUTPUT_BYTES,
|
|
105
|
+
HERDR_START_READY_MS,
|
|
106
|
+
HERDR_START_TIMEOUT_MS,
|
|
107
|
+
HERDR_TASK_LITERAL_INSTRUCTION,
|
|
108
|
+
type HerdrClock,
|
|
109
|
+
type HerdrRun,
|
|
110
|
+
herdrAgentNameFromNonce,
|
|
111
|
+
herdrFreshCall,
|
|
112
|
+
parseHerdrAgentStartResponse,
|
|
113
|
+
parseHerdrPaneGetResponse,
|
|
114
|
+
parseHerdrTabCreateResponse,
|
|
115
|
+
readHerdrErrorCode,
|
|
116
|
+
rejectTmuxPlacementInHerdrContext,
|
|
117
|
+
renderHerdrFreshCall,
|
|
118
|
+
type SpawnFn,
|
|
119
|
+
} from "../pi-extensions/lib/herdr-fresh-call.ts";
|
|
120
|
+
|
|
121
|
+
const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
122
|
+
const MODULE = "pi-extensions/lib/herdr-fresh-call.ts";
|
|
123
|
+
const MODULE_SRC = fs.readFileSync(path.join(REPO_DIR, MODULE), "utf8");
|
|
124
|
+
|
|
125
|
+
let passed = 0;
|
|
126
|
+
function ok(label: string, cond: boolean): void {
|
|
127
|
+
assert.ok(cond, label);
|
|
128
|
+
console.log(` ok ${label}`);
|
|
129
|
+
passed++;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Verbatim `tab create` reply shape, herdr 0.9.0, 2026-09-15 — the new tab AND its initial pane
|
|
133
|
+
* in one reply, which is why this rail never goes looking for "the new one". */
|
|
134
|
+
const TAB_OK = JSON.stringify({
|
|
135
|
+
id: "cli:tab:create",
|
|
136
|
+
result: {
|
|
137
|
+
root_pane: {
|
|
138
|
+
agent_status: "unknown",
|
|
139
|
+
cwd: "/home/junghan/repos/gh/entwurf",
|
|
140
|
+
focused: false,
|
|
141
|
+
pane_id: "w7:p7",
|
|
142
|
+
revision: 0,
|
|
143
|
+
tab_id: "w7:t1",
|
|
144
|
+
terminal_id: "term_65b6e1ce2b3db16",
|
|
145
|
+
workspace_id: "w7",
|
|
146
|
+
},
|
|
147
|
+
tab: {
|
|
148
|
+
agent_status: "unknown",
|
|
149
|
+
focused: false,
|
|
150
|
+
label: "1",
|
|
151
|
+
number: 1,
|
|
152
|
+
pane_count: 1,
|
|
153
|
+
tab_id: "w7:t1",
|
|
154
|
+
workspace_id: "w7",
|
|
155
|
+
},
|
|
156
|
+
type: "tab_created",
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/** Verbatim `pane get` reply shape, same day. Used for BOTH reads this rail makes: the caller's
|
|
161
|
+
* own pane (where the workspace comes from) and the reclaim's within-generation proof. */
|
|
162
|
+
const PANE_OK = JSON.stringify({
|
|
163
|
+
id: "cli:pane:get",
|
|
164
|
+
result: {
|
|
165
|
+
pane: {
|
|
166
|
+
agent_status: "unknown",
|
|
167
|
+
cwd: "/home/junghan/repos/gh/entwurf",
|
|
168
|
+
focused: true,
|
|
169
|
+
pane_id: "w7:p7",
|
|
170
|
+
revision: 1,
|
|
171
|
+
tab_id: "w7:t1",
|
|
172
|
+
terminal_id: "term_65b6e1ce2b3db16",
|
|
173
|
+
workspace_id: "w7",
|
|
174
|
+
},
|
|
175
|
+
type: "pane_info",
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/** The CALLER's own pane — a different pane in the same workspace, which is the whole point. */
|
|
180
|
+
const CALLER_PANE_OK = PANE_OK.replace('"w7:p7"', '"w7:p1"').replace("term_65b6e1ce2b3db16", "term_65b6e1ce2b3db00");
|
|
181
|
+
|
|
182
|
+
/** Verbatim `agent start` reply for the claude axis, same day. */
|
|
183
|
+
const START_OK = JSON.stringify({
|
|
184
|
+
id: "cli:agent:start",
|
|
185
|
+
result: {
|
|
186
|
+
agent: {
|
|
187
|
+
agent: "claude",
|
|
188
|
+
agent_session: {
|
|
189
|
+
agent: "claude",
|
|
190
|
+
kind: "id",
|
|
191
|
+
source: "herdr:claude",
|
|
192
|
+
value: "e3093930-9113-4c3e-b9d0-672803d38fd8",
|
|
193
|
+
},
|
|
194
|
+
agent_status: "idle",
|
|
195
|
+
interactive_ready: true,
|
|
196
|
+
pane_id: "w7:p7",
|
|
197
|
+
tab_id: "w7:t1",
|
|
198
|
+
terminal_id: "term_65b6e1ce2b3db16",
|
|
199
|
+
terminal_title: "✳ Claude Code",
|
|
200
|
+
workspace_id: "w7",
|
|
201
|
+
},
|
|
202
|
+
argv: ["claude"],
|
|
203
|
+
type: "agent_started",
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* `herdr agent get <name>` — the read the settle window makes. Shape from `AgentInfo`
|
|
209
|
+
* (`[file:line @ c77af189]` `src/api/schema/agents.rs:187-215`), coordinates spliced to the START
|
|
210
|
+
* recording above so the two replies describe the SAME sibling; a fixture whose halves named
|
|
211
|
+
* different agents could not test the binding between them.
|
|
212
|
+
*
|
|
213
|
+
* Built as a FUNCTION of what herdr has been told so far, because that is the whole subject here:
|
|
214
|
+
* the same agent, same pane, same terminal, with and without a session report.
|
|
215
|
+
*/
|
|
216
|
+
function agentGetReply(options: {
|
|
217
|
+
session: boolean;
|
|
218
|
+
paneId?: string;
|
|
219
|
+
terminalId?: string;
|
|
220
|
+
agent?: string;
|
|
221
|
+
/** Answer WITHOUT the fields that bind a reply to our pane — a reply we cannot rebind on. */
|
|
222
|
+
omitBinding?: boolean;
|
|
223
|
+
}): string {
|
|
224
|
+
return JSON.stringify({
|
|
225
|
+
id: "cli:agent:get",
|
|
226
|
+
result: {
|
|
227
|
+
agent: {
|
|
228
|
+
agent: options.agent ?? "claude",
|
|
229
|
+
...(options.session
|
|
230
|
+
? {
|
|
231
|
+
agent_session: {
|
|
232
|
+
agent: "claude",
|
|
233
|
+
kind: "id",
|
|
234
|
+
source: "herdr:claude",
|
|
235
|
+
value: "e3093930-9113-4c3e-b9d0-672803d38fd8",
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
: {}),
|
|
239
|
+
agent_status: "idle",
|
|
240
|
+
interactive_ready: true,
|
|
241
|
+
...(options.omitBinding ? {} : { name: herdrAgentNameFromNonce(NONCE) }),
|
|
242
|
+
pane_id: options.paneId ?? "w7:p7",
|
|
243
|
+
...(options.omitBinding ? {} : { tab_id: "w7:t1" }),
|
|
244
|
+
terminal_id: options.terminalId ?? "term_65b6e1ce2b3db16",
|
|
245
|
+
workspace_id: "w7",
|
|
246
|
+
},
|
|
247
|
+
type: "agent_info",
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The two halves of "this pane is not empty", each on its own.
|
|
254
|
+
*
|
|
255
|
+
* They exist separately because collapsing them is the defect: a pane with an AGENT and no session
|
|
256
|
+
* report is precisely the live Claude Code child the old predicate closed, and a pane with a
|
|
257
|
+
* session and no agent label is how the same predicate used to be the only refusal. Both are
|
|
258
|
+
* derived from the recorded `PANE_OK` so nothing but the contested key differs.
|
|
259
|
+
*/
|
|
260
|
+
const PANE_WITH_AGENT_ONLY = PANE_OK.replace('"agent_status":"unknown"', '"agent":"claude","agent_status":"idle"');
|
|
261
|
+
const PANE_WITH_SESSION_ONLY = PANE_OK.replace(
|
|
262
|
+
'"agent_status":"unknown"',
|
|
263
|
+
'"agent_session":{"agent":"claude","kind":"id","source":"herdr:claude","value":"e3093930-9113-4c3e-b9d0-672803d38fd8"},"agent_status":"unknown"',
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
/** Verbatim failure envelope — measured against a NONEXISTENT pane, so nothing was created. */
|
|
267
|
+
const START_ERR = JSON.stringify({
|
|
268
|
+
error: { code: "invalid_agent_argument", message: "agent arguments cannot be encoded safely for the target shell" },
|
|
269
|
+
id: "cli:agent:start",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const CALLER = "20260914T174741-2e9ba9";
|
|
273
|
+
const NONCE = "herdr-fresh-call-0123456789abcdef01234567";
|
|
274
|
+
const HERDR_ENV = { HERDR_ENV: "1", HERDR_BIN_PATH: "/home/operator/.local/bin/herdr", HERDR_PANE_ID: "w7:p1" };
|
|
275
|
+
|
|
276
|
+
type ScriptedReply =
|
|
277
|
+
| { status: number; stdout?: string; stderr?: string }
|
|
278
|
+
| ((args: readonly string[]) => { status: number; stdout?: string; stderr?: string });
|
|
279
|
+
|
|
280
|
+
/** A runner that records what it was asked and answers from a fixed script. A reply may be a
|
|
281
|
+
* FUNCTION of the request, which is how the start fixture echoes an argv the way herdr does
|
|
282
|
+
* (`src/app/agents.rs:197-199`: canonical executable + our args) instead of a frozen literal
|
|
283
|
+
* that could never drift and therefore could never be checked. */
|
|
284
|
+
function scriptedRun(replies: readonly ScriptedReply[]): { run: HerdrRun; calls: string[][] } {
|
|
285
|
+
const calls: string[][] = [];
|
|
286
|
+
let index = 0;
|
|
287
|
+
const run: HerdrRun = (args) => {
|
|
288
|
+
calls.push([...args]);
|
|
289
|
+
const entry = replies[index++] ?? { status: 1 };
|
|
290
|
+
const reply = typeof entry === "function" ? entry(args) : entry;
|
|
291
|
+
// A PROMISE, like the production runner: the rail must await every herdr call, and a
|
|
292
|
+
// fixture that answered synchronously would let a blocking regression stay green here.
|
|
293
|
+
return Promise.resolve({ status: reply.status, stdout: reply.stdout ?? "", stderr: reply.stderr ?? "" });
|
|
294
|
+
};
|
|
295
|
+
return { run, calls };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A clock the settle window can be driven through without spending it.
|
|
300
|
+
*
|
|
301
|
+
* `sleep` does not wait — it ADVANCES the clock by exactly what it was asked for. So a cell that
|
|
302
|
+
* exhausts the whole 5s window costs nothing in wall time while still walking the same number of
|
|
303
|
+
* polls production would. A gate that slept for real would either be slow or would have to shrink
|
|
304
|
+
* the bound it is testing, and a bound shrunk for a test is a different bound.
|
|
305
|
+
*/
|
|
306
|
+
function fakeClock(): HerdrClock & { readonly elapsed: () => number } {
|
|
307
|
+
let ms = 1_000_000;
|
|
308
|
+
const start = ms;
|
|
309
|
+
return {
|
|
310
|
+
now: () => ms,
|
|
311
|
+
sleep: (by) => {
|
|
312
|
+
ms += by;
|
|
313
|
+
return Promise.resolve();
|
|
314
|
+
},
|
|
315
|
+
elapsed: () => ms - start,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** herdr's own echo: the canonical executable for the requested kind, then everything after `--`. */
|
|
320
|
+
function startReplyEchoingArgv(base: string, executable = "claude"): ScriptedReply {
|
|
321
|
+
return (args) => {
|
|
322
|
+
const parsed = JSON.parse(base) as { result: { argv: string[] } };
|
|
323
|
+
parsed.result.argv = [executable, ...args.slice(args.indexOf("--") + 1)];
|
|
324
|
+
return { status: 0, stdout: JSON.stringify(parsed) };
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Records the calls a refusal should never make. It RETURNS rather than throws: a throw would
|
|
329
|
+
* end the gate with a message carrying no QK token, and a kill that cannot be attributed to its
|
|
330
|
+
* claim is not a kill. */
|
|
331
|
+
function neverRun(): { run: HerdrRun; calls: string[][] } {
|
|
332
|
+
const calls: string[][] = [];
|
|
333
|
+
const run: HerdrRun = (args) => {
|
|
334
|
+
calls.push([...args]);
|
|
335
|
+
return Promise.resolve({ status: 1, stdout: "", stderr: "" });
|
|
336
|
+
};
|
|
337
|
+
return { run, calls };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function main(): Promise<void> {
|
|
341
|
+
console.log("[check-herdr-fresh-call]");
|
|
342
|
+
|
|
343
|
+
// ── the fence ────────────────────────────────────────────────────────────────────────
|
|
344
|
+
const code = MODULE_SRC.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
345
|
+
const specifiers = [...MODULE_SRC.matchAll(/^import\s[^;]*?from\s+"([^"]+)";$/gm)].map((m) => m[1]);
|
|
346
|
+
ok(
|
|
347
|
+
"[QK:HFC-RAIL-IMPORT-FENCE] the herdr rail imports only node builtins and the neutral composition leaf — one mux or entwurf import here would re-couple the two rails and make either one undeletable",
|
|
348
|
+
specifiers.length > 0 &&
|
|
349
|
+
specifiers.every((s) => s.startsWith("node:") || s === "./fresh-call-composition.ts") &&
|
|
350
|
+
!/from\s+"\.\/(mux-|entwurf-)/.test(code),
|
|
351
|
+
);
|
|
352
|
+
// tmux is NAMED in this module, and only in one place: the refusal of a tmux seat input.
|
|
353
|
+
// That is the opposite of a fallback, so the guard pins WHERE the word may appear rather
|
|
354
|
+
// than banning it — a banned word would have pushed the refusal into silence.
|
|
355
|
+
// The reject-hint table is caller-facing prose whose JOB is to explain the tmux-seat refusal,
|
|
356
|
+
// so it is excluded from the scan the same way comments are: what must not contain tmux is the
|
|
357
|
+
// launch path, not the sentence telling an operator why their seat was refused.
|
|
358
|
+
const hintTable = code.slice(code.indexOf("const HERDR_REJECT_HINT"), code.indexOf("function renderRecovery"));
|
|
359
|
+
const tmuxLines = code
|
|
360
|
+
.replace(hintTable, "")
|
|
361
|
+
.split("\n")
|
|
362
|
+
.filter((line) => /tmux/i.test(line));
|
|
363
|
+
ok(
|
|
364
|
+
"[QK:HFC-PILOT-CLOSED] the pilot set is exactly pi + claude-code, and tmux appears in this module ONLY as the refusal of a tmux seat — never as a fallback launch",
|
|
365
|
+
HERDR_FRESH_CALL_BACKENDS.length === 2 &&
|
|
366
|
+
HERDR_FRESH_CALL_BACKENDS.includes("pi") &&
|
|
367
|
+
HERDR_FRESH_CALL_BACKENDS.includes("claude-code") &&
|
|
368
|
+
tmuxLines.length > 0 &&
|
|
369
|
+
tmuxLines.every((line) => /tmux-rejected|tmuxSession|rejectTmuxPlacementInHerdrContext|tmuxPlacement/.test(line)),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
// ── option G encoding ────────────────────────────────────────────────────────────────
|
|
373
|
+
// An encoder that refuses and an encoder that throws are the same thing to a caller: no
|
|
374
|
+
// faithful argv. Both land on the claim's own assertion rather than ending the gate with
|
|
375
|
+
// an unattributable stack.
|
|
376
|
+
//
|
|
377
|
+
// WHAT MOVED HERE (#116, 2026-09-17). The whole first turn used to be one JSON literal behind
|
|
378
|
+
// "follow the decoded instructions", and `[GLG 직접, 날것 PC]` a Sonnet 5 sibling refused exactly
|
|
379
|
+
// that shape. Now the FRAMING is folded to one line as prose and only the operator's TASK is a
|
|
380
|
+
// literal, so the round-trip claim is about the task — which is the half that actually carries
|
|
381
|
+
// bytes somebody wrote — while the framing gets its own claim about surviving the fold verbatim.
|
|
382
|
+
const FRAMING = composeFreshCallFraming({
|
|
383
|
+
backend: "claude-code",
|
|
384
|
+
callerGardenId: "20260101T010101-aaaaaa",
|
|
385
|
+
nonce: "mux-fresh-call-deadbeefdeadbeefdeadbeef",
|
|
386
|
+
openingLine: HERDR_FRESH_CALL_OPENING_LINE,
|
|
387
|
+
});
|
|
388
|
+
const decodeOrNull = (task: string): string | null => {
|
|
389
|
+
try {
|
|
390
|
+
const result = encodeBirthPrompt(FRAMING, task);
|
|
391
|
+
if (!result.ok) return null;
|
|
392
|
+
const at = result.argv.indexOf(HERDR_TASK_LITERAL_INSTRUCTION);
|
|
393
|
+
if (at === -1) return null;
|
|
394
|
+
const decoded: unknown = JSON.parse(result.argv.slice(at + HERDR_TASK_LITERAL_INSTRUCTION.length));
|
|
395
|
+
return typeof decoded === "string" ? decoded : null;
|
|
396
|
+
} catch {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
const argvOrNull = (task: string): string | null => {
|
|
401
|
+
try {
|
|
402
|
+
const result = encodeBirthPrompt(FRAMING, task);
|
|
403
|
+
return result.ok ? result.argv : null;
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const multiline = 'line one\nline two\ttabbed\n"quoted" and \\backslash\n한글 — em dash';
|
|
410
|
+
ok(
|
|
411
|
+
"[QK:HFC-ENCODE-ROUNDTRIP] the operator's TASK decodes back byte for byte — every newline, tab, quote, backslash and non-ASCII character, with no trim and no normalisation",
|
|
412
|
+
decodeOrNull(multiline) === multiline,
|
|
413
|
+
);
|
|
414
|
+
const plainArgv = argvOrNull("do the thing");
|
|
415
|
+
ok(
|
|
416
|
+
"[QK:HFC-FRAMING-IS-PROSE] the framing rides as PLAIN PROSE folded onto one line — every composed sentence survives the fold verbatim and in order, and nothing asks the sibling to decode its instructions",
|
|
417
|
+
plainArgv !== null &&
|
|
418
|
+
FRAMING.filter((line) => line.length > 0).every((line) => plainArgv.includes(line)) &&
|
|
419
|
+
FRAMING.filter((line) => line.length > 0).reduce<{ ok: boolean; at: number }>(
|
|
420
|
+
(acc, line) => {
|
|
421
|
+
const at = plainArgv.indexOf(line, acc.at);
|
|
422
|
+
return { ok: acc.ok && at >= acc.at, at: at + line.length };
|
|
423
|
+
},
|
|
424
|
+
{ ok: true, at: 0 },
|
|
425
|
+
).ok &&
|
|
426
|
+
!/decoded instructions|as if they were this message/i.test(plainArgv) &&
|
|
427
|
+
!/Do not inspect environment variables/i.test(plainArgv),
|
|
428
|
+
);
|
|
429
|
+
const c1Task = "head\u0085middle\u009fdel\u007ftail";
|
|
430
|
+
const c1Argv = argvOrNull(c1Task);
|
|
431
|
+
ok(
|
|
432
|
+
"[QK:HFC-ENCODE-NO-CONTROL] the whole argv carries zero Unicode Cc — JSON.stringify leaves DEL and the C1 block LITERAL, and herdr refuses an argument containing any of them",
|
|
433
|
+
containsControlChar(c1Task) && c1Argv !== null && !containsControlChar(c1Argv) && decodeOrNull(c1Task) === c1Task,
|
|
434
|
+
);
|
|
435
|
+
// A control character in the FRAMING is the rail's own input, not the caller's — it is refused
|
|
436
|
+
// rather than escaped, because the framing is prose and an escape would be read as text.
|
|
437
|
+
ok(
|
|
438
|
+
"[QK:HFC-FRAMING-CONTROL-REFUSED] a control character in the rail's own framing is a named refusal, never silently escaped into the prose a sibling reads",
|
|
439
|
+
(() => {
|
|
440
|
+
const bad = encodeBirthPrompt([...FRAMING, "tail\u0007bell"], "task");
|
|
441
|
+
return !bad.ok && bad.reason === "herdr-argv-control-character";
|
|
442
|
+
})(),
|
|
443
|
+
);
|
|
444
|
+
// 16000 is the public task cap the delivery surface mirrors; the worst case is every
|
|
445
|
+
// character needing a six-byte \uXXXX escape.
|
|
446
|
+
const maxTask = "\n한\u009f".repeat(5333) + "a";
|
|
447
|
+
const maxArgv = argvOrNull(maxTask);
|
|
448
|
+
ok(
|
|
449
|
+
"[QK:HFC-ENCODE-MAX-TASK] a maximum-length public task encodes, stays Cc-free and still round-trips — the escape pass has no length cliff",
|
|
450
|
+
maxTask.length === 16000 && maxArgv !== null && !containsControlChar(maxArgv) && decodeOrNull(maxTask) === maxTask,
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// ── argv grammar ─────────────────────────────────────────────────────────────────────
|
|
454
|
+
// Pure builders are checked before full launch paths so a framing mutant is attributed to
|
|
455
|
+
// its own contract rather than making an earlier integration success cell fail first.
|
|
456
|
+
const tabArgs = buildHerdrTabCreateArgs({ workspaceId: "w7", cwd: "/repo/dir" });
|
|
457
|
+
ok(
|
|
458
|
+
"[QK:HFC-TAB-ARGV] one placement policy — a NEW TAB in the caller's own workspace, named explicitly so herdr cannot default it to the focused one, focus left alone, cwd carried literally, and BOTH identity carriers scrubbed by explicit repeated --env",
|
|
459
|
+
tabArgs.join(" ") ===
|
|
460
|
+
"tab create --workspace w7 --no-focus --cwd /repo/dir --env PI_SESSION_ID= --env PI_AGENT_ID=",
|
|
461
|
+
);
|
|
462
|
+
ok(
|
|
463
|
+
"a cwd-free tab create omits --cwd entirely rather than sending an empty value, and the rail adds no layout axis of its own — no label, no ratio, no direction",
|
|
464
|
+
!buildHerdrTabCreateArgs({ workspaceId: "w7" }).includes("--cwd") &&
|
|
465
|
+
!tabArgs.includes("--label") &&
|
|
466
|
+
!tabArgs.includes("--ratio") &&
|
|
467
|
+
!tabArgs.includes("--direction"),
|
|
468
|
+
);
|
|
469
|
+
const startArgs = buildHerdrAgentStartArgs({
|
|
470
|
+
agentName: "entwurf-abc",
|
|
471
|
+
kind: "claude",
|
|
472
|
+
paneId: "w7:pA",
|
|
473
|
+
backendArgs: ["PROMPT", "--model=opus"],
|
|
474
|
+
});
|
|
475
|
+
// `[측정 2026-09-17, oracle, LIVE]` this flag was missing and herdr used its own 30s default: a
|
|
476
|
+
// cold Claude Code start outran it, and a healthy sibling came back as `herdr: timeout` over an
|
|
477
|
+
// unreclaimed pane. The two bounds must also stay ordered — herdr has to expire FIRST, or our
|
|
478
|
+
// process kill lands instead and the named reason is lost.
|
|
479
|
+
//
|
|
480
|
+
// Asserted BEFORE the exact-argv cell below: that cell spells the whole command out, so it
|
|
481
|
+
// would fail first on a missing flag and take this claim's attribution with it.
|
|
482
|
+
ok(
|
|
483
|
+
"[QK:HFC-START-READY-BOUND] the start carries an EXPLICIT readiness timeout, and it expires before our own process bound so herdr's named answer is the one we report",
|
|
484
|
+
// Deliberately NOT asserting where `--` sits: that separator is HFC-START-ARGV's claim, and
|
|
485
|
+
// reading it here would make a mutant of THAT contract fail on THIS token.
|
|
486
|
+
startArgs.includes("--timeout") &&
|
|
487
|
+
startArgs[startArgs.indexOf("--timeout") + 1] === String(HERDR_START_READY_MS) &&
|
|
488
|
+
HERDR_START_READY_MS < HERDR_START_TIMEOUT_MS,
|
|
489
|
+
);
|
|
490
|
+
ok(
|
|
491
|
+
"[QK:HFC-START-ARGV] the backend's own argv rides after `--`, under the REQUESTED kind and the initial pane of the tab just created",
|
|
492
|
+
startArgs.join(" ") ===
|
|
493
|
+
`agent start entwurf-abc --kind claude --pane w7:pA --timeout ${HERDR_START_READY_MS} -- PROMPT --model=opus`,
|
|
494
|
+
);
|
|
495
|
+
ok(
|
|
496
|
+
"pane get and pane close take a POSITIONAL pane id — measured: `--pane` is a usage error on both",
|
|
497
|
+
buildHerdrPaneGetArgs("w7:pA").join(" ") === "pane get w7:pA" &&
|
|
498
|
+
buildHerdrPaneCloseArgs("w7:pA").join(" ") === "pane close w7:pA",
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
// ── refusals happen before anything exists ───────────────────────────────────────────
|
|
502
|
+
ok(
|
|
503
|
+
'[QK:HFC-TMUX-PLACEMENT-REFUSED] ANY defined placement object is refused by name — `{}` and `{tmuxSession:""}` too, because reading the member instead of the object would give a caller who asked for a seat and mistyped it a default-placed sibling under a green receipt',
|
|
504
|
+
rejectTmuxPlacementInHerdrContext({ tmuxSession: "org" }) === "herdr-placement-tmux-rejected" &&
|
|
505
|
+
rejectTmuxPlacementInHerdrContext({}) === "herdr-placement-tmux-rejected" &&
|
|
506
|
+
rejectTmuxPlacementInHerdrContext({ tmuxSession: "" }) === "herdr-placement-tmux-rejected" &&
|
|
507
|
+
rejectTmuxPlacementInHerdrContext(undefined) === null,
|
|
508
|
+
);
|
|
509
|
+
const base = { backend: "pi", model: "openai-codex/gpt-5.6-sol", task: "do it", callerGardenId: CALLER } as const;
|
|
510
|
+
const refusals: [string, Parameters<typeof herdrFreshCall>[0], NodeJS.ProcessEnv, string][] = [
|
|
511
|
+
["herdr-context-missing", { ...base }, {}, "herdr-context-missing"],
|
|
512
|
+
["caller pane", { ...base }, { HERDR_ENV: "1", HERDR_BIN_PATH: "/x/herdr" }, "herdr-caller-pane-missing"],
|
|
513
|
+
["backend", { ...base, backend: "codex" }, HERDR_ENV, "herdr-backend-unsupported"],
|
|
514
|
+
["tmux seat", { ...base, placement: { tmuxSession: "org" } }, HERDR_ENV, "herdr-placement-tmux-rejected"],
|
|
515
|
+
["cwd", { ...base, cwd: "relative/dir" }, HERDR_ENV, "cwd-not-absolute"],
|
|
516
|
+
];
|
|
517
|
+
// The five INPUT reasons are owned by HFC-INPUT-PARITY below, which asserts both their
|
|
518
|
+
// words and that they too reach no CLI. Splitting them keeps each mutant attributable: a
|
|
519
|
+
// defect in "nothing runs yet" and a defect in "which word" are different repairs.
|
|
520
|
+
let everyRefusalPreMutation = true;
|
|
521
|
+
for (const [, params, env, expected] of refusals) {
|
|
522
|
+
const { run, calls } = neverRun();
|
|
523
|
+
const result = await herdrFreshCall(params, run, env, NONCE);
|
|
524
|
+
if (result.ok || result.reason !== expected || calls.length !== 0) everyRefusalPreMutation = false;
|
|
525
|
+
}
|
|
526
|
+
ok(
|
|
527
|
+
"[QK:HFC-PREMUTATION-ONLY] the rail-shaped refusals — context, caller pane, backend, tmux seat, cwd — are decided with the herdr CLI never invoked, so a refused call leaves no tab behind",
|
|
528
|
+
everyRefusalPreMutation,
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
// ── where the sibling is placed comes from herdr, not from a pane id's shape ─────────
|
|
532
|
+
ok(
|
|
533
|
+
"[QK:HFC-CALLER-PANE-BOUND] the caller pane-get answer must be ABOUT the pane we asked about — a readable reply describing a DIFFERENT pane is refused by its own name rather than folded into `unparsable`, because its workspace is not evidence about this caller and believing it is the same silent relocation, arriving through the read instead of through an omitted --workspace",
|
|
534
|
+
await (async () => {
|
|
535
|
+
const { run, calls } = scriptedRun([{ status: 0, stdout: PANE_OK }]);
|
|
536
|
+
const drifted = await herdrFreshCall({ ...base, backend: "claude-code" }, run, HERDR_ENV, NONCE);
|
|
537
|
+
return (
|
|
538
|
+
!drifted.ok &&
|
|
539
|
+
drifted.reason === "herdr-caller-pane-drift" &&
|
|
540
|
+
calls.length === 1 &&
|
|
541
|
+
calls[0].join(" ") === "pane get w7:p1"
|
|
542
|
+
);
|
|
543
|
+
})(),
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
ok(
|
|
547
|
+
"[QK:HFC-CALLER-WORKSPACE] the new tab's workspace is read from herdr's OWN answer about the caller's own pane — and when that answer is unusable the call REFUSES, because omitting --workspace still succeeds and puts the sibling in whatever workspace happens to be focused",
|
|
548
|
+
await (async () => {
|
|
549
|
+
const cases: [ScriptedReply, string][] = [
|
|
550
|
+
[
|
|
551
|
+
{ status: 1, stderr: '{"error":{"code":"pane_not_found"},"id":"cli:pane:get"}' },
|
|
552
|
+
"herdr-caller-pane-get-failed",
|
|
553
|
+
],
|
|
554
|
+
[{ status: 0, stdout: "usage: herdr pane get <pane_id>" }, "herdr-caller-pane-unparsable"],
|
|
555
|
+
[
|
|
556
|
+
{ status: 0, stdout: CALLER_PANE_OK.replace(',"workspace_id":"w7"', "").replace('"workspace_id":"w7",', "") },
|
|
557
|
+
"herdr-caller-workspace-missing",
|
|
558
|
+
],
|
|
559
|
+
];
|
|
560
|
+
for (const [reply, expected] of cases) {
|
|
561
|
+
const { run, calls } = scriptedRun([reply]);
|
|
562
|
+
const result = await herdrFreshCall({ ...base, backend: "claude-code" }, run, HERDR_ENV, NONCE);
|
|
563
|
+
// Exactly ONE herdr call, and it is the read. Nothing was created to reclaim, which
|
|
564
|
+
// is why these are refusals and not launch failures.
|
|
565
|
+
if (result.ok || result.reason !== expected) return false;
|
|
566
|
+
if (calls.length !== 1 || calls.join(" ") !== "pane,get,w7:p1") return false;
|
|
567
|
+
}
|
|
568
|
+
// And on the happy path the workspace herdr reported is the one --workspace carries.
|
|
569
|
+
const { run, calls } = scriptedRun([
|
|
570
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
571
|
+
{ status: 0, stdout: TAB_OK },
|
|
572
|
+
startReplyEchoingArgv(START_OK),
|
|
573
|
+
]);
|
|
574
|
+
const okResult = await herdrFreshCall({ ...base, backend: "claude-code" }, run, HERDR_ENV, NONCE);
|
|
575
|
+
return (
|
|
576
|
+
okResult.ok &&
|
|
577
|
+
calls[0].join(" ") === "pane get w7:p1" &&
|
|
578
|
+
calls[1].slice(0, 4).join(" ") === "tab create --workspace w7" &&
|
|
579
|
+
okResult.receipt.herdrWorkspaceId === "w7"
|
|
580
|
+
);
|
|
581
|
+
})(),
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
const name = herdrAgentNameFromNonce(NONCE);
|
|
585
|
+
ok(
|
|
586
|
+
"[QK:HFC-AGENT-NAME-OPAQUE] the herdr agent name is derived from the per-call nonce, valid under herdr's own [a-z][a-z0-9_-]{0,31} rule, and discloses no role, title or address",
|
|
587
|
+
/^[a-z][a-z0-9_-]{0,31}$/.test(name) &&
|
|
588
|
+
name === herdrAgentNameFromNonce(NONCE) &&
|
|
589
|
+
name !== herdrAgentNameFromNonce(`${NONCE}x`) &&
|
|
590
|
+
!name.includes(CALLER),
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
// ── parsing ──────────────────────────────────────────────────────────────────────────
|
|
594
|
+
const tab = parseHerdrTabCreateResponse(TAB_OK);
|
|
595
|
+
ok(
|
|
596
|
+
"[QK:HFC-PARSE-STRICT] a payload missing any coordinate a launch needs is DECLINED whole — a half-read reply is how a launch starts believing a coordinate it never got",
|
|
597
|
+
tab !== null &&
|
|
598
|
+
tab.tabId === "w7:t1" &&
|
|
599
|
+
tab.workspaceId === "w7" &&
|
|
600
|
+
tab.rootPane.paneId === "w7:p7" &&
|
|
601
|
+
tab.rootPane.terminalId === "term_65b6e1ce2b3db16" &&
|
|
602
|
+
parseHerdrTabCreateResponse('{"id":"cli:tab:create","result":{"root_pane":{"pane_id":"w7:p7"}}}') === null &&
|
|
603
|
+
// An EMPTY coordinate is not a coordinate. A present-but-blank field reads as
|
|
604
|
+
// "herdr answered" to a `typeof` check alone, and would travel into the receipt as
|
|
605
|
+
// a terminal id no conditional close could ever match.
|
|
606
|
+
parseHerdrTabCreateResponse(TAB_OK.replace("term_65b6e1ce2b3db16", "")) === null &&
|
|
607
|
+
parseHerdrTabCreateResponse(TAB_OK.replace('"w7:p7"', '""')) === null &&
|
|
608
|
+
// The tab half, held to the same standard as the pane half.
|
|
609
|
+
parseHerdrTabCreateResponse(TAB_OK.replace(/"tab_id":"w7:t1","workspace_id":"w7"\}/, '"tab_id":"w7:t1"}')) ===
|
|
610
|
+
null &&
|
|
611
|
+
parseHerdrTabCreateResponse(
|
|
612
|
+
TAB_OK.replace('"tab_id":"w7:t1","workspace_id":"w7"}', '"tab_id":"","workspace_id":"w7"}'),
|
|
613
|
+
) === null &&
|
|
614
|
+
parseHerdrTabCreateResponse("usage: herdr tab create [OPTIONS]") === null &&
|
|
615
|
+
parseHerdrTabCreateResponse("") === null &&
|
|
616
|
+
parseHerdrPaneGetResponse(PANE_OK)?.paneId === "w7:p7" &&
|
|
617
|
+
parseHerdrPaneGetResponse("") === null,
|
|
618
|
+
);
|
|
619
|
+
const started = parseHerdrAgentStartResponse(START_OK);
|
|
620
|
+
ok(
|
|
621
|
+
"the agent_started reply yields the same pane facts, and agent_session is read as PRESENCE only",
|
|
622
|
+
started !== null && started.pane.paneId === "w7:p7" && started.pane.hasAgentSession === true,
|
|
623
|
+
);
|
|
624
|
+
ok(
|
|
625
|
+
"[QK:HFC-TAB-HALVES-AGREE] the tab-create reply's two halves must name the SAME tab and the SAME workspace, and both must SAY so — absent is disagreement, not optionality, because a reply that names a tab only once cannot be checked against itself and assembling it is how a launch starts in one tab while the receipt names another",
|
|
626
|
+
parseHerdrTabCreateResponse(TAB_OK) !== null &&
|
|
627
|
+
// root_pane says one tab, tab says another.
|
|
628
|
+
parseHerdrTabCreateResponse(
|
|
629
|
+
TAB_OK.replace('"tab_id":"w7:t1","terminal_id"', '"tab_id":"w7:t9","terminal_id"'),
|
|
630
|
+
) === null &&
|
|
631
|
+
// …and the same for the workspace, in BOTH directions of disagreement.
|
|
632
|
+
parseHerdrTabCreateResponse(
|
|
633
|
+
TAB_OK.replace(
|
|
634
|
+
'"terminal_id":"term_65b6e1ce2b3db16","workspace_id":"w7"',
|
|
635
|
+
'"terminal_id":"term_65b6e1ce2b3db16","workspace_id":"w9"',
|
|
636
|
+
),
|
|
637
|
+
) === null &&
|
|
638
|
+
// ABSENT on the root pane is not "optional", it is disagreement: a reply that names a
|
|
639
|
+
// tab only once cannot be checked against itself, and believing it is how a launch
|
|
640
|
+
// starts in one tab while the receipt names another.
|
|
641
|
+
parseHerdrTabCreateResponse(TAB_OK.replace('"tab_id":"w7:t1","terminal_id"', '"terminal_id"')) === null &&
|
|
642
|
+
parseHerdrTabCreateResponse(
|
|
643
|
+
TAB_OK.replace(
|
|
644
|
+
'"terminal_id":"term_65b6e1ce2b3db16","workspace_id":"w7"',
|
|
645
|
+
'"terminal_id":"term_65b6e1ce2b3db16"',
|
|
646
|
+
),
|
|
647
|
+
) === null,
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
const opaque = parseHerdrTabCreateResponse(TAB_OK.replace('"w7:p7"', '"w7:pA"'));
|
|
651
|
+
ok(
|
|
652
|
+
"[QK:HFC-PANE-ID-OPAQUE] a `pA`-style pane id survives byte-identical — measured: the tenth pane is `w7:pA`, not `w7:p10`, so any decimal parser is already wrong, and this rail reads the workspace out of herdr's reply rather than off the front of an id",
|
|
653
|
+
opaque !== null && opaque.rootPane.paneId === "w7:pA",
|
|
654
|
+
);
|
|
655
|
+
ok(
|
|
656
|
+
"herdr's own error code is quoted out of its envelope rather than retyped into a vocabulary of ours that would go stale",
|
|
657
|
+
readHerdrErrorCode(START_ERR) === "invalid_agent_argument" &&
|
|
658
|
+
readHerdrErrorCode("usage: herdr agent start") === undefined,
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
// ── the launch, and what its receipt may say ─────────────────────────────────────────
|
|
662
|
+
const { run, calls } = scriptedRun([
|
|
663
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
664
|
+
{ status: 0, stdout: TAB_OK },
|
|
665
|
+
startReplyEchoingArgv(START_OK),
|
|
666
|
+
]);
|
|
667
|
+
const launched = await herdrFreshCall({ ...base, backend: "claude-code", model: "opus" }, run, HERDR_ENV, NONCE);
|
|
668
|
+
const receipt = launched.ok ? launched.receipt : null;
|
|
669
|
+
const receiptText = JSON.stringify(receipt ?? {});
|
|
670
|
+
ok(
|
|
671
|
+
"[QK:HFC-RECEIPT-NO-ADDRESS] the launch receipt carries the view and what was requested — and no garden id, no native session id, no screen text and none of herdr's own readiness judgements",
|
|
672
|
+
receipt !== null &&
|
|
673
|
+
receipt.herdrTabId === "w7:t1" &&
|
|
674
|
+
receipt.herdrWorkspaceId === "w7" &&
|
|
675
|
+
receipt.herdrPaneId === "w7:p7" &&
|
|
676
|
+
receipt.herdrTerminalId === "term_65b6e1ce2b3db16" &&
|
|
677
|
+
receipt.requestedKind === "claude" &&
|
|
678
|
+
receipt.nonce === NONCE &&
|
|
679
|
+
!("gardenId" in receipt) &&
|
|
680
|
+
!("nativeSessionId" in receipt) &&
|
|
681
|
+
!receiptText.includes("e3093930-9113-4c3e-b9d0-672803d38fd8") &&
|
|
682
|
+
!receiptText.includes("interactive_ready") &&
|
|
683
|
+
!receiptText.includes("agent_status") &&
|
|
684
|
+
!receiptText.includes("Claude Code"),
|
|
685
|
+
);
|
|
686
|
+
ok(
|
|
687
|
+
"the launch is one read and exactly two mutations — caller pane get, tab create, agent start — with the encoded one-line prompt as the first backend argument",
|
|
688
|
+
calls.length === 3 &&
|
|
689
|
+
calls[0].join(" ") === "pane get w7:p1" &&
|
|
690
|
+
calls[1][1] === "create" &&
|
|
691
|
+
calls[2][1] === "start" &&
|
|
692
|
+
calls[2][calls[2].indexOf("--") + 1].startsWith(HERDR_FRESH_CALL_OPENING_LINE) &&
|
|
693
|
+
calls[2][calls[2].indexOf("--") + 1].includes(HERDR_TASK_LITERAL_INSTRUCTION) &&
|
|
694
|
+
!containsControlChar(calls[2][calls[2].indexOf("--") + 1]),
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
// ── conditional close ────────────────────────────────────────────────────────────────
|
|
698
|
+
const created = { paneId: "w7:p7", terminalId: "term_65b6e1ce2b3db16" };
|
|
699
|
+
const same = parseHerdrPaneGetResponse(PANE_OK);
|
|
700
|
+
ok(
|
|
701
|
+
"[QK:HFC-CLOSE-WITHIN-GENERATION] a close needs the SAME pane id AND the same terminal AND no agent session — measured: a server restart returns the same pane ids on different terminals, so a bare pane id is not authority to close anything",
|
|
702
|
+
decideConditionalClose(created, same, false).close === true &&
|
|
703
|
+
decideConditionalClose(
|
|
704
|
+
created,
|
|
705
|
+
parseHerdrPaneGetResponse(PANE_OK.replace("term_65b6e1ce2b3db16", "term_other")),
|
|
706
|
+
false,
|
|
707
|
+
).close === false &&
|
|
708
|
+
decideConditionalClose(created, parseHerdrPaneGetResponse(PANE_OK.replace('"w7:p7"', '"w7:p9"')), false).close ===
|
|
709
|
+
false,
|
|
710
|
+
);
|
|
711
|
+
ok(
|
|
712
|
+
"[QK:HFC-CLOSE-REFUSES-OCCUPIED] a pane herdr says an AGENT is in is never closed, whether or not anybody has reported that agent's session id — `[측정 oracle 2026-09-18]` the predicate that read only the session id closed a pane holding a booted Claude Code child, because on that rail the id arrives through a separate hook the child makes AFTER it starts",
|
|
713
|
+
decideConditionalClose(created, parseHerdrPaneGetResponse(PANE_WITH_AGENT_ONLY), false).close === false &&
|
|
714
|
+
// The two facts are independent, and the occupancy one is the one that has to hold on
|
|
715
|
+
// its own: a pane with an agent and NO session is exactly the live child that was killed.
|
|
716
|
+
parseHerdrPaneGetResponse(PANE_WITH_AGENT_ONLY)?.hasAgentSession === false &&
|
|
717
|
+
parseHerdrPaneGetResponse(PANE_WITH_AGENT_ONLY)?.agent === "claude" &&
|
|
718
|
+
// And an empty pane is still closable, so this is a narrowing rather than a disabling.
|
|
719
|
+
decideConditionalClose(created, same, false).close === true,
|
|
720
|
+
);
|
|
721
|
+
const matrix: [ReturnType<typeof decideConditionalClose>, string][] = [
|
|
722
|
+
[decideConditionalClose(created, null, true), "pane-get-failed"],
|
|
723
|
+
[decideConditionalClose(created, null, false), "pane-get-unparsable"],
|
|
724
|
+
[
|
|
725
|
+
decideConditionalClose(created, parseHerdrPaneGetResponse(PANE_OK.replace('"w7:p7"', '"w7:p9"')), false),
|
|
726
|
+
"pane-id-mismatch",
|
|
727
|
+
],
|
|
728
|
+
[
|
|
729
|
+
decideConditionalClose(
|
|
730
|
+
created,
|
|
731
|
+
parseHerdrPaneGetResponse(PANE_OK.replace("term_65b6e1ce2b3db16", "term_other")),
|
|
732
|
+
false,
|
|
733
|
+
),
|
|
734
|
+
"terminal-id-mismatch",
|
|
735
|
+
],
|
|
736
|
+
[
|
|
737
|
+
decideConditionalClose(created, parseHerdrPaneGetResponse(PANE_WITH_SESSION_ONLY), false),
|
|
738
|
+
"agent-session-present",
|
|
739
|
+
],
|
|
740
|
+
[decideConditionalClose(created, parseHerdrPaneGetResponse(PANE_WITH_AGENT_ONLY), false), "agent-present"],
|
|
741
|
+
];
|
|
742
|
+
ok(
|
|
743
|
+
"[QK:HFC-ORPHAN-NAMED] every way the proof can fail has its OWN name — get failed, unreadable, wrong pane, wrong terminal, an agent in the pane, someone else's session — because the operator's next move differs for each",
|
|
744
|
+
matrix.every(([decision, reason]) => decision.close === false && decision.reason === reason) &&
|
|
745
|
+
new Set(matrix.map(([, reason]) => reason)).size === 6,
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const failing = scriptedRun([
|
|
749
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
750
|
+
{ status: 0, stdout: TAB_OK },
|
|
751
|
+
{ status: 1, stderr: START_ERR },
|
|
752
|
+
{ status: 0, stdout: PANE_OK },
|
|
753
|
+
{ status: 0 },
|
|
754
|
+
]);
|
|
755
|
+
const failed = await herdrFreshCall(
|
|
756
|
+
{ ...base, backend: "claude-code", model: "opus" },
|
|
757
|
+
failing.run,
|
|
758
|
+
HERDR_ENV,
|
|
759
|
+
NONCE,
|
|
760
|
+
);
|
|
761
|
+
ok(
|
|
762
|
+
"a start that fails after the tab was created reclaims the pane conditionally, quotes herdr's own error code, and reports the reclaim in the same breath — and the close is the PANE we own, never `tab close`, which takes a bare tab id and would take a stranger's pane with it",
|
|
763
|
+
!failed.ok &&
|
|
764
|
+
failed.reason === "herdr-agent-start-failed" &&
|
|
765
|
+
"herdrErrorCode" in failed &&
|
|
766
|
+
failed.herdrErrorCode === "invalid_agent_argument" &&
|
|
767
|
+
"recovery" in failed &&
|
|
768
|
+
failed.recovery.outcome === "closed" &&
|
|
769
|
+
failing.calls.length === 5 &&
|
|
770
|
+
failing.calls[4].join(" ") === "pane close w7:p7" &&
|
|
771
|
+
!failing.calls.some((call) => call[0] === "tab" && call[1] === "close"),
|
|
772
|
+
);
|
|
773
|
+
ok(
|
|
774
|
+
"[QK:HFC-CREATE-OUTCOME-HONEST] a failed tab create reports whether anything EXISTS, and the header never claims more than the recovery knows — herdr's OWN error envelope means it declined before making a tab (`none`), while a nonzero status with no envelope is OUR bound cutting the call off and a tab MAY be sitting there (`unknown`), as is a reply we could not read",
|
|
775
|
+
await (async () => {
|
|
776
|
+
// herdr declined by name: envelope present, quoted, nothing created.
|
|
777
|
+
const declined = scriptedRun([
|
|
778
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
779
|
+
{
|
|
780
|
+
status: 1,
|
|
781
|
+
stderr: '{"error":{"code":"workspace_not_found","message":"workspace w7 not found"},"id":"cli:tab:create"}',
|
|
782
|
+
},
|
|
783
|
+
]);
|
|
784
|
+
const none = await herdrFreshCall({ ...base, backend: "claude-code" }, declined.run, HERDR_ENV, NONCE);
|
|
785
|
+
if (none.ok || none.reason !== "herdr-tab-create-failed") return false;
|
|
786
|
+
if (!("recovery" in none) || none.recovery.outcome !== "none") return false;
|
|
787
|
+
if (!("herdrErrorCode" in none) || none.herdrErrorCode !== "workspace_not_found") return false;
|
|
788
|
+
// No close was attempted for something that does not exist.
|
|
789
|
+
if (declined.calls.length !== 2) return false;
|
|
790
|
+
const noneText = renderHerdrFreshCall(none).text;
|
|
791
|
+
if (!noneText.includes("recovery: none") || noneText.includes("orphan-unreclaimed")) return false;
|
|
792
|
+
// The header must not say the tab was created when the hint says it was not.
|
|
793
|
+
if (noneText.includes("failed after the tab was created")) return false;
|
|
794
|
+
|
|
795
|
+
// OUR bound, not herdr's answer: the production runner reports a timeout/kill as the
|
|
796
|
+
// same nonzero status with a PLAIN-TEXT stderr. A tab may exist.
|
|
797
|
+
const cutOff = scriptedRun([
|
|
798
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
799
|
+
{ status: 1, stderr: "herdr tab create --workspace w7: no exit status (timeout 30000ms or signal null)" },
|
|
800
|
+
]);
|
|
801
|
+
const unknown = await herdrFreshCall({ ...base, backend: "claude-code" }, cutOff.run, HERDR_ENV, NONCE);
|
|
802
|
+
if (unknown.ok || unknown.reason !== "herdr-tab-create-failed") return false;
|
|
803
|
+
if (!("recovery" in unknown) || unknown.recovery.outcome !== "unknown") return false;
|
|
804
|
+
if ("herdrErrorCode" in unknown && unknown.herdrErrorCode !== undefined) return false;
|
|
805
|
+
const unknownText = renderHerdrFreshCall(unknown).text;
|
|
806
|
+
if (!unknownText.includes("UNKNOWN") || unknownText.includes("orphan-unreclaimed")) return false;
|
|
807
|
+
|
|
808
|
+
// A readable status-0 reply we could not parse is the same honesty: may exist.
|
|
809
|
+
const garbled = scriptedRun([
|
|
810
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
811
|
+
{ status: 0, stdout: '{"id":"cli:tab:create","result":{"tab":{"tab_id":"w7:t1"}}}' },
|
|
812
|
+
]);
|
|
813
|
+
const unread = await herdrFreshCall({ ...base, backend: "claude-code" }, garbled.run, HERDR_ENV, NONCE);
|
|
814
|
+
return (
|
|
815
|
+
!unread.ok &&
|
|
816
|
+
unread.reason === "herdr-tab-create-unparsable" &&
|
|
817
|
+
"recovery" in unread &&
|
|
818
|
+
unread.recovery.outcome === "unknown" &&
|
|
819
|
+
garbled.calls.length === 2
|
|
820
|
+
);
|
|
821
|
+
})(),
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
const stubborn = scriptedRun([
|
|
825
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
826
|
+
{ status: 0, stdout: TAB_OK },
|
|
827
|
+
{ status: 1, stderr: START_ERR },
|
|
828
|
+
{ status: 0, stdout: PANE_OK.replace("term_65b6e1ce2b3db16", "term_other") },
|
|
829
|
+
]);
|
|
830
|
+
const orphaned = await herdrFreshCall(
|
|
831
|
+
{ ...base, backend: "claude-code", model: "opus" },
|
|
832
|
+
stubborn.run,
|
|
833
|
+
HERDR_ENV,
|
|
834
|
+
NONCE,
|
|
835
|
+
);
|
|
836
|
+
ok(
|
|
837
|
+
"when the proof does not hold the pane is LEFT ALONE and named — four calls, no close of any kind attempted",
|
|
838
|
+
!orphaned.ok &&
|
|
839
|
+
"recovery" in orphaned &&
|
|
840
|
+
orphaned.recovery.outcome === "orphan-unreclaimed" &&
|
|
841
|
+
orphaned.recovery.reason === "terminal-id-mismatch" &&
|
|
842
|
+
stubborn.calls.length === 4 &&
|
|
843
|
+
!stubborn.calls.some((call) => call[1] === "close"),
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
// ── the amendment cells: input parity and post-create binding ───────────────────────
|
|
847
|
+
ok(
|
|
848
|
+
"[QK:HFC-INPUT-PARITY] the input contract is the SHARED one — same five words, same order, same trimming as the tmux rail — so a caller does not learn a different vocabulary by being inside herdr",
|
|
849
|
+
(await (async () => {
|
|
850
|
+
const cases: [Parameters<typeof herdrFreshCall>[0], string][] = [
|
|
851
|
+
[{ ...base, callerGardenId: null }, "caller-identity-unavailable"],
|
|
852
|
+
[{ ...base, model: " " }, "model-empty"],
|
|
853
|
+
[{ ...base, model: "bad model" }, "model-invalid"],
|
|
854
|
+
[{ ...base, task: "\t\n " }, "task-empty"],
|
|
855
|
+
[{ ...base, task: "x".repeat(16001) }, "task-too-long"],
|
|
856
|
+
];
|
|
857
|
+
for (const [params, expected] of cases) {
|
|
858
|
+
const { run, calls } = neverRun();
|
|
859
|
+
const result = await herdrFreshCall(params, run, HERDR_ENV, NONCE);
|
|
860
|
+
// The words AND the silence: an input refusal must also leave no pane behind.
|
|
861
|
+
if (result.ok || result.reason !== expected || calls.length !== 0) return false;
|
|
862
|
+
}
|
|
863
|
+
return true;
|
|
864
|
+
})()) &&
|
|
865
|
+
(await (async () => {
|
|
866
|
+
// The cap is the boundary, not a vibe: exactly at it the call proceeds to herdr.
|
|
867
|
+
const { run, calls } = scriptedRun([
|
|
868
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
869
|
+
{ status: 0, stdout: TAB_OK },
|
|
870
|
+
startReplyEchoingArgv(START_OK),
|
|
871
|
+
]);
|
|
872
|
+
const atCap = await herdrFreshCall(
|
|
873
|
+
{ ...base, backend: "claude-code", task: "x".repeat(16000) },
|
|
874
|
+
run,
|
|
875
|
+
HERDR_ENV,
|
|
876
|
+
NONCE,
|
|
877
|
+
);
|
|
878
|
+
return atCap.ok && calls.length === 3;
|
|
879
|
+
})()),
|
|
880
|
+
);
|
|
881
|
+
ok(
|
|
882
|
+
"[QK:HFC-CWD-HASH-IS-DATA] a `#` in the start directory is DATA on this rail — the tmux leaf refuses it because tmux format-expands a `-c` value and a `#(\u2026)` was observed EXECUTING, while herdr clones `--cwd` into the request untouched, so importing that refusal would be borrowed authority with a stated reason that is false here",
|
|
883
|
+
await (async () => {
|
|
884
|
+
// A real directory, because the classifier's other two answers are existence facts and
|
|
885
|
+
// a fixture that did not exist would pass for the wrong reason.
|
|
886
|
+
const hashDir = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-herdr-hash."));
|
|
887
|
+
const withHash = path.join(hashDir, "#(date)");
|
|
888
|
+
fs.mkdirSync(withHash);
|
|
889
|
+
try {
|
|
890
|
+
const { run, calls } = scriptedRun([
|
|
891
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
892
|
+
{ status: 0, stdout: TAB_OK },
|
|
893
|
+
startReplyEchoingArgv(START_OK),
|
|
894
|
+
]);
|
|
895
|
+
const result = await herdrFreshCall({ ...base, backend: "claude-code", cwd: withHash }, run, HERDR_ENV, NONCE);
|
|
896
|
+
return (
|
|
897
|
+
classifyHerdrCwd(withHash) === null &&
|
|
898
|
+
result.ok &&
|
|
899
|
+
// It reaches herdr as the literal it is — unquoted, untrimmed, unexpanded.
|
|
900
|
+
calls[1].includes("--cwd") &&
|
|
901
|
+
calls[1][calls[1].indexOf("--cwd") + 1] === withHash &&
|
|
902
|
+
result.receipt.cwd === withHash
|
|
903
|
+
);
|
|
904
|
+
} finally {
|
|
905
|
+
fs.rmSync(hashDir, { recursive: true, force: true });
|
|
906
|
+
}
|
|
907
|
+
})(),
|
|
908
|
+
);
|
|
909
|
+
ok(
|
|
910
|
+
"[QK:HFC-CWD-EMPTY-IS-OMITTED] an empty cwd means OMITTED, exactly as the public verb has always meant it — reading it as a path made a documented no-op into an invalid-directory refusal",
|
|
911
|
+
await (async () => {
|
|
912
|
+
const { run, calls } = scriptedRun([
|
|
913
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
914
|
+
{ status: 0, stdout: TAB_OK },
|
|
915
|
+
startReplyEchoingArgv(START_OK),
|
|
916
|
+
]);
|
|
917
|
+
const result = await herdrFreshCall({ ...base, backend: "claude-code", cwd: "" }, run, HERDR_ENV, NONCE);
|
|
918
|
+
return result.ok && !calls[1].includes("--cwd") && !("cwd" in result.receipt);
|
|
919
|
+
})(),
|
|
920
|
+
);
|
|
921
|
+
ok(
|
|
922
|
+
"[QK:HFC-TAB-OCCUPIED] a brand-new tab whose initial pane came back holding an agent does NOT get started into — it is named, and the reclaim that follows correctly refuses to close somebody else's pane",
|
|
923
|
+
await (async () => {
|
|
924
|
+
const agent = JSON.parse(START_OK).result.agent;
|
|
925
|
+
const occupiedTab = JSON.stringify({
|
|
926
|
+
id: "cli:tab:create",
|
|
927
|
+
result: { root_pane: agent, tab: { tab_id: "w7:t1", workspace_id: "w7" }, type: "tab_created" },
|
|
928
|
+
});
|
|
929
|
+
const occupiedPane = JSON.stringify({ id: "cli:pane:get", result: { pane: agent, type: "pane_info" } });
|
|
930
|
+
const { run, calls } = scriptedRun([
|
|
931
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
932
|
+
{ status: 0, stdout: occupiedTab },
|
|
933
|
+
{ status: 0, stdout: occupiedPane },
|
|
934
|
+
]);
|
|
935
|
+
const result = await herdrFreshCall({ ...base, backend: "claude-code" }, run, HERDR_ENV, NONCE);
|
|
936
|
+
return (
|
|
937
|
+
!result.ok &&
|
|
938
|
+
result.reason === "herdr-tab-root-pane-occupied" &&
|
|
939
|
+
"recovery" in result &&
|
|
940
|
+
result.recovery.outcome === "orphan-unreclaimed" &&
|
|
941
|
+
// OCCUPANCY is what refuses here, and it is the stronger of the two facts: this
|
|
942
|
+
// stranger's pane would be refused even if nobody had reported their session id.
|
|
943
|
+
result.recovery.reason === "agent-present" &&
|
|
944
|
+
!calls.some((call) => call[1] === "start")
|
|
945
|
+
);
|
|
946
|
+
})(),
|
|
947
|
+
);
|
|
948
|
+
ok(
|
|
949
|
+
"[QK:HFC-START-PANE-BINDING] a start reporting a pane, terminal OR TAB that is not the one we created is a NAMED failure, not a success — the whole point of this placement policy is which tab the sibling is in — and the reclaim still runs from the tab-create receipt",
|
|
950
|
+
await (async () => {
|
|
951
|
+
const drifts = [
|
|
952
|
+
START_OK.replace('"w7:p7"', '"w7:p9"'),
|
|
953
|
+
START_OK.replace("term_65b6e1ce2b3db16", "term_other"),
|
|
954
|
+
START_OK.replace('"w7:t1"', '"w7:t9"'),
|
|
955
|
+
];
|
|
956
|
+
for (const drifted of drifts) {
|
|
957
|
+
const { run, calls } = scriptedRun([
|
|
958
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
959
|
+
{ status: 0, stdout: TAB_OK },
|
|
960
|
+
startReplyEchoingArgv(drifted),
|
|
961
|
+
{ status: 0, stdout: PANE_OK },
|
|
962
|
+
{ status: 0 },
|
|
963
|
+
]);
|
|
964
|
+
const result = await herdrFreshCall({ ...base, backend: "claude-code" }, run, HERDR_ENV, NONCE);
|
|
965
|
+
if (result.ok || result.reason !== "herdr-agent-start-pane-drift") return false;
|
|
966
|
+
if (calls[3].join(" ") !== "pane get w7:p7" || calls[4].join(" ") !== "pane close w7:p7") return false;
|
|
967
|
+
}
|
|
968
|
+
return true;
|
|
969
|
+
})(),
|
|
970
|
+
);
|
|
971
|
+
const witnessless = (): string => {
|
|
972
|
+
const parsed = JSON.parse(START_OK);
|
|
973
|
+
parsed.result.agent.agent_session = null;
|
|
974
|
+
return JSON.stringify(parsed);
|
|
975
|
+
};
|
|
976
|
+
ok(
|
|
977
|
+
"[QK:HFC-WITNESS-SETTLED-BY-READ] a start reply with no agent session is SETTLED by re-reading, not judged on the spot — the rail asks `agent get <the exact name it started>` (never a listing, never a keystroke) and a session that arrives on a later read makes the launch a success that reports what the wait cost",
|
|
978
|
+
await (async () => {
|
|
979
|
+
const clock = fakeClock();
|
|
980
|
+
const { run, calls } = scriptedRun([
|
|
981
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
982
|
+
{ status: 0, stdout: TAB_OK },
|
|
983
|
+
startReplyEchoingArgv(witnessless()),
|
|
984
|
+
{ status: 0, stdout: agentGetReply({ session: false }) },
|
|
985
|
+
{ status: 0, stdout: agentGetReply({ session: true }) },
|
|
986
|
+
]);
|
|
987
|
+
const result = await herdrFreshCall({ ...base, backend: "claude-code" }, run, HERDR_ENV, NONCE, clock);
|
|
988
|
+
const reads = calls.filter((call) => call[0] === "agent" && call[1] === "get");
|
|
989
|
+
return (
|
|
990
|
+
result.ok &&
|
|
991
|
+
result.receipt.witness.state === "reported" &&
|
|
992
|
+
result.receipt.witness.reads === 2 &&
|
|
993
|
+
result.receipt.witness.settleMs === 2 * HERDR_AGENT_SESSION_POLL_MS &&
|
|
994
|
+
reads.length === 2 &&
|
|
995
|
+
// EXACT NAME, both times. A listing would make us pick "the one that looks like
|
|
996
|
+
// ours" out of every agent on the server — the guess this rail refuses.
|
|
997
|
+
reads.every(
|
|
998
|
+
(call) => JSON.stringify(call) === JSON.stringify(buildHerdrAgentGetArgs(herdrAgentNameFromNonce(NONCE))),
|
|
999
|
+
) &&
|
|
1000
|
+
!calls.some((call) => call[0] === "pane" && call[1] === "close")
|
|
1001
|
+
);
|
|
1002
|
+
})(),
|
|
1003
|
+
);
|
|
1004
|
+
ok(
|
|
1005
|
+
"[QK:HFC-WITNESS-ABSENCE-KEEPS] the settle window EXPIRING is a successful launch carrying an `unavailable` witness, and it closes nothing — the session id comes from the child's own one-shot reporter, so `nobody told herdr` never becomes `nobody is there`, and the shape this replaces closed a pane holding a live Claude Code child 0.585s after that child had written its own birth record",
|
|
1006
|
+
await (async () => {
|
|
1007
|
+
const clock = fakeClock();
|
|
1008
|
+
const silent: ScriptedReply = { status: 0, stdout: agentGetReply({ session: false }) };
|
|
1009
|
+
const { run, calls } = scriptedRun([
|
|
1010
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
1011
|
+
{ status: 0, stdout: TAB_OK },
|
|
1012
|
+
startReplyEchoingArgv(witnessless()),
|
|
1013
|
+
...Array.from({ length: 200 }, () => silent),
|
|
1014
|
+
]);
|
|
1015
|
+
const result = await herdrFreshCall({ ...base, backend: "claude-code" }, run, HERDR_ENV, NONCE, clock);
|
|
1016
|
+
const expectedReads = HERDR_AGENT_SESSION_SETTLE_MS / HERDR_AGENT_SESSION_POLL_MS;
|
|
1017
|
+
return (
|
|
1018
|
+
result.ok &&
|
|
1019
|
+
result.receipt.witness.state === "unavailable" &&
|
|
1020
|
+
result.receipt.witness.settleMs === HERDR_AGENT_SESSION_SETTLE_MS &&
|
|
1021
|
+
result.receipt.witness.reads === expectedReads &&
|
|
1022
|
+
// The whole point: nothing was closed, and the window is bounded rather than open.
|
|
1023
|
+
!calls.some((call) => call[0] === "pane" && call[1] === "close") &&
|
|
1024
|
+
clock.elapsed() === HERDR_AGENT_SESSION_SETTLE_MS &&
|
|
1025
|
+
// And the operator is TOLD, in the receipt, rather than left to infer it.
|
|
1026
|
+
renderHerdrFreshCall(result).text.includes("unavailable after") &&
|
|
1027
|
+
!renderHerdrFreshCall(result).isError
|
|
1028
|
+
);
|
|
1029
|
+
})(),
|
|
1030
|
+
);
|
|
1031
|
+
ok(
|
|
1032
|
+
"[QK:HFC-WITNESS-UNREAD-SAYS-NOTHING] an expiry in which NO read was ever readable is its own word — `unobserved`, not `unavailable` — and the receipt then asserts nothing about the sibling: the sentence that says herdr still showed the agent in our pane belongs only to the branch where a readable reply actually said so, and it closes nothing either way",
|
|
1033
|
+
await (async () => {
|
|
1034
|
+
const clock = fakeClock();
|
|
1035
|
+
const blind: ScriptedReply = { status: 1, stderr: "boom" };
|
|
1036
|
+
const { run, calls } = scriptedRun([
|
|
1037
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
1038
|
+
{ status: 0, stdout: TAB_OK },
|
|
1039
|
+
startReplyEchoingArgv(witnessless()),
|
|
1040
|
+
...Array.from({ length: 200 }, () => blind),
|
|
1041
|
+
]);
|
|
1042
|
+
const result = await herdrFreshCall({ ...base, backend: "claude-code" }, run, HERDR_ENV, NONCE, clock);
|
|
1043
|
+
if (!result.ok) return false;
|
|
1044
|
+
const text = renderHerdrFreshCall(result).text;
|
|
1045
|
+
return (
|
|
1046
|
+
result.receipt.witness.state === "unobserved" &&
|
|
1047
|
+
result.receipt.witness.settleMs === HERDR_AGENT_SESSION_SETTLE_MS &&
|
|
1048
|
+
text.includes("unobserved after") &&
|
|
1049
|
+
text.includes("says nothing about whether the sibling is running") &&
|
|
1050
|
+
// THE DEFECT THIS CELL OWNS: a receipt that never got a readable answer used to
|
|
1051
|
+
// borrow the other branch's sentence and assert current liveness.
|
|
1052
|
+
!text.includes("herdr DID still show this agent") &&
|
|
1053
|
+
!calls.some((call) => call[0] === "pane" && call[1] === "close")
|
|
1054
|
+
);
|
|
1055
|
+
})(),
|
|
1056
|
+
);
|
|
1057
|
+
ok(
|
|
1058
|
+
"[QK:HFC-WITNESS-BINDING-COMPLETE] a readable reply that OMITS tab, name or kind cannot carry the exact-rebinding claim, so it is not a witness — the wait keeps polling and expires `unobserved` rather than counting a half-bound answer as having seen our sibling, and it is not treated as drift either, because a field that is absent disagrees with nothing",
|
|
1059
|
+
await (async () => {
|
|
1060
|
+
const clock = fakeClock();
|
|
1061
|
+
const halfBound: ScriptedReply = { status: 0, stdout: agentGetReply({ session: true, omitBinding: true }) };
|
|
1062
|
+
const { run, calls } = scriptedRun([
|
|
1063
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
1064
|
+
{ status: 0, stdout: TAB_OK },
|
|
1065
|
+
startReplyEchoingArgv(witnessless()),
|
|
1066
|
+
...Array.from({ length: 200 }, () => halfBound),
|
|
1067
|
+
]);
|
|
1068
|
+
const result = await herdrFreshCall({ ...base, backend: "claude-code" }, run, HERDR_ENV, NONCE, clock);
|
|
1069
|
+
return (
|
|
1070
|
+
result.ok &&
|
|
1071
|
+
result.receipt.witness.state === "unobserved" &&
|
|
1072
|
+
!calls.some((call) => call[0] === "pane" && call[1] === "close")
|
|
1073
|
+
);
|
|
1074
|
+
})(),
|
|
1075
|
+
);
|
|
1076
|
+
ok(
|
|
1077
|
+
"[QK:HFC-WITNESS-DRIFT-VANISHED] the one failure this stage can name is a READABLE reply about a different agent — a reply we could not read only costs a poll, because herdr answers a missing agent and a socket that blinked with the same exit status and this module cannot tell them apart",
|
|
1078
|
+
await (async () => {
|
|
1079
|
+
const clock = fakeClock();
|
|
1080
|
+
const { run, calls } = scriptedRun([
|
|
1081
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
1082
|
+
{ status: 0, stdout: TAB_OK },
|
|
1083
|
+
startReplyEchoingArgv(witnessless()),
|
|
1084
|
+
// Unreadable, then failed: neither is evidence that the agent is gone.
|
|
1085
|
+
{ status: 0, stdout: "not json" },
|
|
1086
|
+
{ status: 1, stderr: "boom" },
|
|
1087
|
+
// Readable, and about a pane that is not the one we opened.
|
|
1088
|
+
{ status: 0, stdout: agentGetReply({ session: true, paneId: "w7:p9" }) },
|
|
1089
|
+
{ status: 0, stdout: PANE_OK },
|
|
1090
|
+
{ status: 0 },
|
|
1091
|
+
]);
|
|
1092
|
+
const drifted = await herdrFreshCall({ ...base, backend: "claude-code" }, run, HERDR_ENV, NONCE, clock);
|
|
1093
|
+
return (
|
|
1094
|
+
!drifted.ok &&
|
|
1095
|
+
drifted.reason === "herdr-agent-start-vanished" &&
|
|
1096
|
+
calls.filter((call) => call[0] === "agent" && call[1] === "get").length === 3
|
|
1097
|
+
);
|
|
1098
|
+
})(),
|
|
1099
|
+
);
|
|
1100
|
+
ok(
|
|
1101
|
+
"[QK:HFC-START-ARGV-FIDELITY] the echoed argv must be the canonical executable plus EXACTLY the argv we passed after `--` — a sibling started with a different framing is one we did not compose, and nothing downstream would ever reveal it",
|
|
1102
|
+
await (async () => {
|
|
1103
|
+
const composed = JSON.parse(START_OK);
|
|
1104
|
+
const { run, calls } = scriptedRun([
|
|
1105
|
+
{ status: 0, stdout: CALLER_PANE_OK },
|
|
1106
|
+
{ status: 0, stdout: TAB_OK },
|
|
1107
|
+
{
|
|
1108
|
+
status: 0,
|
|
1109
|
+
stdout: JSON.stringify({
|
|
1110
|
+
...composed,
|
|
1111
|
+
result: { ...composed.result, argv: ["claude", "SOMETHING-ELSE"] },
|
|
1112
|
+
}),
|
|
1113
|
+
},
|
|
1114
|
+
{ status: 0, stdout: PANE_OK },
|
|
1115
|
+
{ status: 0 },
|
|
1116
|
+
]);
|
|
1117
|
+
const drift = await herdrFreshCall({ ...base, backend: "claude-code" }, run, HERDR_ENV, NONCE);
|
|
1118
|
+
const passed = calls[2].slice(calls[2].indexOf("--") + 1);
|
|
1119
|
+
return (
|
|
1120
|
+
!drift.ok &&
|
|
1121
|
+
drift.reason === "herdr-agent-start-argv-drift" &&
|
|
1122
|
+
argvMatchesRequest(["claude", ...passed], "claude-code", passed) &&
|
|
1123
|
+
!argvMatchesRequest(["claude"], "claude-code", passed) &&
|
|
1124
|
+
!argvMatchesRequest(null, "claude-code", passed) &&
|
|
1125
|
+
!argvMatchesRequest(["pi", ...passed], "claude-code", passed)
|
|
1126
|
+
);
|
|
1127
|
+
})(),
|
|
1128
|
+
);
|
|
1129
|
+
|
|
1130
|
+
// ── the scheduling cells: the runner must not hold the caller's event loop ───────────
|
|
1131
|
+
// `[측정 2026-09-14, C4 첫 LIVE]` the child's FIRST act is a callback onto the caller's control
|
|
1132
|
+
// socket, and on this rail the caller is inside `agent start` when it arrives. A synchronous
|
|
1133
|
+
// child wait made that callback unreadable until the wait ended. These cells drive the REAL
|
|
1134
|
+
// production runner with a REAL child process (`node`, not a herdr stand-in) and watch what the
|
|
1135
|
+
// same process manages to do while it is in flight. "The function says async" proves nothing
|
|
1136
|
+
// and is not asserted anywhere below.
|
|
1137
|
+
{
|
|
1138
|
+
// A real unix socket, so the thing being serviced mid-call is actual IO, not a timer alone.
|
|
1139
|
+
const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), "herdr-runner-"));
|
|
1140
|
+
const socketPath = path.join(socketDir, "s.sock");
|
|
1141
|
+
const server = net.createServer((socket) => socket.end("pong\n"));
|
|
1142
|
+
await new Promise<void>((resolve) => server.listen(socketPath, resolve));
|
|
1143
|
+
const spawned: ChildProcess[] = [];
|
|
1144
|
+
const recordingSpawn: SpawnFn = (bin, args, opts) => {
|
|
1145
|
+
const child = spawn(bin, args, opts);
|
|
1146
|
+
spawned.push(child);
|
|
1147
|
+
return child as unknown as ReturnType<SpawnFn>;
|
|
1148
|
+
};
|
|
1149
|
+
try {
|
|
1150
|
+
// ── NONBLOCKING ──────────────────────────────────────────────────────────────
|
|
1151
|
+
{
|
|
1152
|
+
const run = createHerdrRunner(process.execPath, process.env, recordingSpawn);
|
|
1153
|
+
const started = Date.now();
|
|
1154
|
+
let timerFiredAt: number | null = null;
|
|
1155
|
+
let socketAnsweredAt: number | null = null;
|
|
1156
|
+
setTimeout(() => {
|
|
1157
|
+
timerFiredAt = Date.now();
|
|
1158
|
+
}, 20);
|
|
1159
|
+
const socketDone = new Promise<void>((resolve) => {
|
|
1160
|
+
const client = net.createConnection(socketPath, () => {
|
|
1161
|
+
client.on("data", () => {
|
|
1162
|
+
socketAnsweredAt = Date.now();
|
|
1163
|
+
client.end();
|
|
1164
|
+
resolve();
|
|
1165
|
+
});
|
|
1166
|
+
});
|
|
1167
|
+
});
|
|
1168
|
+
// ~1.2s of real child, chosen so both observations must land WHILE it runs.
|
|
1169
|
+
const result = await run(["-e", "setTimeout(() => process.stdout.write('done'), 1200)"]);
|
|
1170
|
+
const resolvedAt = Date.now();
|
|
1171
|
+
await socketDone;
|
|
1172
|
+
ok(
|
|
1173
|
+
"[QK:HERDR-RUNNER-NONBLOCKING] while a real herdr child is in flight the caller's loop keeps serving: a 20ms timer and a real unix-socket round trip BOTH completed before the call resolved, and the timer's latency stayed bounded — a synchronous child wait is exactly what made the C4 sibling's first callback unreadable" +
|
|
1174
|
+
` — child=${resolvedAt - started}ms timer=${timerFiredAt === null ? "never" : timerFiredAt - started}ms socket=${socketAnsweredAt === null ? "never" : socketAnsweredAt - started}ms`,
|
|
1175
|
+
result.status === 0 &&
|
|
1176
|
+
result.stdout === "done" &&
|
|
1177
|
+
timerFiredAt !== null &&
|
|
1178
|
+
socketAnsweredAt !== null &&
|
|
1179
|
+
timerFiredAt < resolvedAt &&
|
|
1180
|
+
socketAnsweredAt < resolvedAt &&
|
|
1181
|
+
timerFiredAt - started < 600,
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ── TIMEOUT KILLS ────────────────────────────────────────────────────────────
|
|
1186
|
+
{
|
|
1187
|
+
// The bounds are injected ONLY to reach the kill inside a gate's patience; the
|
|
1188
|
+
// numbers production runs under are the exported constants, asserted below.
|
|
1189
|
+
const run = createHerdrRunner(process.execPath, process.env, recordingSpawn, {
|
|
1190
|
+
startMs: 150,
|
|
1191
|
+
cliMs: 150,
|
|
1192
|
+
maxOutputBytes: HERDR_MAX_OUTPUT_BYTES,
|
|
1193
|
+
});
|
|
1194
|
+
const before = spawned.length;
|
|
1195
|
+
const result = await run(["-e", "setTimeout(() => {}, 60000)"]);
|
|
1196
|
+
const child = spawned[before];
|
|
1197
|
+
// The child must be GONE, not merely abandoned: a bound that stops waiting while
|
|
1198
|
+
// the child keeps a pane is a bound we only claimed to have.
|
|
1199
|
+
let alive = true;
|
|
1200
|
+
for (let i = 0; i < 100 && alive; i++) {
|
|
1201
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
1202
|
+
try {
|
|
1203
|
+
process.kill(child!.pid!, 0);
|
|
1204
|
+
} catch {
|
|
1205
|
+
alive = false;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
ok(
|
|
1209
|
+
"[QK:HERDR-RUNNER-TIMEOUT-KILLS] a child that outlives its bound is KILLED and the call settles once with the no-exit-status shape — a bound that only stops waiting leaves the child holding a pane we just reported gone" +
|
|
1210
|
+
` — status=${result.status} stderr=${JSON.stringify(result.stderr)} alive=${alive}`,
|
|
1211
|
+
result.status === 1 &&
|
|
1212
|
+
result.stderr.includes("no exit status (timeout 150ms") &&
|
|
1213
|
+
alive === false &&
|
|
1214
|
+
// The production bounds themselves are untouched by the seam.
|
|
1215
|
+
HERDR_START_TIMEOUT_MS === 300_000 &&
|
|
1216
|
+
HERDR_CLI_TIMEOUT_MS === 30_000,
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// ── OUTPUT BOUNDED ───────────────────────────────────────────────────────────
|
|
1221
|
+
{
|
|
1222
|
+
const run = createHerdrRunner(process.execPath, process.env, recordingSpawn, {
|
|
1223
|
+
startMs: 5_000,
|
|
1224
|
+
cliMs: 5_000,
|
|
1225
|
+
maxOutputBytes: 64 * 1024,
|
|
1226
|
+
});
|
|
1227
|
+
const result = await run([
|
|
1228
|
+
"-e",
|
|
1229
|
+
"const chunk='x'.repeat(64*1024); for (let i=0;i<64;i++) process.stdout.write(chunk); setTimeout(()=>{}, 30000);",
|
|
1230
|
+
]);
|
|
1231
|
+
ok(
|
|
1232
|
+
"[QK:HERDR-RUNNER-OUTPUT-BOUNDED] a runaway child is cut off AT the cap and reported as a failure, never buffered without limit or truncated into a parse we would believe" +
|
|
1233
|
+
` — status=${result.status} stdout=${result.stdout.length}B stderr=${JSON.stringify(result.stderr.slice(0, 120))}`,
|
|
1234
|
+
result.status === 1 &&
|
|
1235
|
+
result.stdout === "" &&
|
|
1236
|
+
result.stderr.includes(`output exceeded ${64 * 1024} bytes`) &&
|
|
1237
|
+
HERDR_MAX_OUTPUT_BYTES === 8 * 1024 * 1024,
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
} finally {
|
|
1241
|
+
for (const child of spawned) child.kill("SIGKILL");
|
|
1242
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
1243
|
+
fs.rmSync(socketDir, { recursive: true, force: true });
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
console.log(`\n[check-herdr-fresh-call] ${passed} assertions ok`);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
await main();
|