@junghanacs/entwurf 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -2
- package/BASELINE.md +3 -1
- package/CHANGELOG.md +658 -0
- package/DELIVERY.md +167 -29
- package/README.md +104 -14
- package/VERIFY.md +73 -11
- package/docs/external-mcp-host.md +16 -6
- package/docs/setup-clean-host.md +73 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +54 -31
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
- 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 +289 -57
- 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-runner.js +3 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
- 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 +171 -263
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +65 -31
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
- package/package.json +4 -4
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/codex-caller-seat.ts +204 -0
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +325 -52
- 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-runner.ts +3 -2
- package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
- 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 +257 -304
- package/run.sh +522 -10
- package/scripts/check-codex-app-server-launch.ts +445 -0
- 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-facts.ts +12 -6
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-entwurf-v2-production.ts +42 -1
- package/scripts/check-entwurf-v2-send.ts +26 -7
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +19 -5
- 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-launch-tmux.ts +331 -35
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-release-gate-outcomes.ts +12 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-app-server-launch.sh +275 -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/codex-socket-path.ts +33 -0
- package/scripts/codex-terminal-title-config.py +500 -0
- package/scripts/codex_toml_io.py +121 -0
- 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/lib/codex-fresh-live-protocol.ts +13 -5
- package/scripts/lib/codex-fresh-source-receipts.ts +29 -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-app-server-launch.json +157 -0
- package/scripts/mutants/codex-caller-seat.json +334 -0
- 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 +184 -25
- 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/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/mutants/v2-surface.json +22 -0
- 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-codex-birth.sh +281 -12
- package/scripts/smoke-codex-config-state.sh +192 -3
- package/scripts/smoke-codex-fresh-live.ts +277 -37
- package/scripts/smoke-entwurf-chain-live.ts +50 -0
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-setup-verdict.sh +13 -11
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
* `sendRpcCommand` reject `connection closed before response` (the 2026-05-18
|
|
17
17
|
* receiver-stuck backstop the settled-guard preserves).
|
|
18
18
|
* 5. get_info runtime helper parses/formats cwd/model/idle once for every caller.
|
|
19
|
+
* 7. accepted-connection disconnect policy — a REAL peer that hangs up mid-exchange must not
|
|
20
|
+
* reach this process as an uncaught exception (the 2026-09-14 C4 incident: a resident pi died
|
|
21
|
+
* writing a late response to a sender that had already timed out), must not be diagnosed, and
|
|
22
|
+
* an error that is NOT a disconnect must be diagnosed exactly once without rethrowing.
|
|
23
|
+
* 8. ordering — the surface installs that policy before setEncoding and before the data handler.
|
|
19
24
|
*
|
|
20
25
|
* No model / auth / pi process — only `net.Server` on a tmp socket, so it rides `pnpm run check:full`.
|
|
21
26
|
*/
|
|
@@ -27,6 +32,7 @@ import * as os from "node:os";
|
|
|
27
32
|
import * as path from "node:path";
|
|
28
33
|
import { fileURLToPath } from "node:url";
|
|
29
34
|
import {
|
|
35
|
+
attachAcceptedSocketDisconnectPolicy,
|
|
30
36
|
fetchControlSocketRuntimeInfo,
|
|
31
37
|
formatRuntimeModel,
|
|
32
38
|
formatSenderInfoBlock,
|
|
@@ -217,6 +223,138 @@ async function main(): Promise<void> {
|
|
|
217
223
|
ok("6: undefined origin/replyable render nothing", !formatSenderInfoBlock(base).includes("origin"));
|
|
218
224
|
}
|
|
219
225
|
|
|
226
|
+
// ── 7. accepted-connection disconnect policy (the server half of the wire) ──
|
|
227
|
+
// `[측정 2026-09-14]` a resident pi was killed by a late response written to a socket whose
|
|
228
|
+
// peer had already timed out and hung up. The EPIPE arrived asynchronously as an `error`
|
|
229
|
+
// event, and an `error` event with no listener is an uncaught exception. These cells drive a
|
|
230
|
+
// REAL unix socket to a real peer disconnect — no fake control server, no synthetic stand-in
|
|
231
|
+
// for the crash itself.
|
|
232
|
+
{
|
|
233
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rpc-disconnect-"));
|
|
234
|
+
const socketPath = path.join(dir, "s.sock");
|
|
235
|
+
const diagnostics: string[] = [];
|
|
236
|
+
const accepted: net.Socket[] = [];
|
|
237
|
+
const server = net.createServer((socket) => {
|
|
238
|
+
// Exactly what the surface does, and the ONLY error listener on this connection: if the
|
|
239
|
+
// policy stops absorbing, nothing else here is catching it.
|
|
240
|
+
attachAcceptedSocketDisconnectPolicy(socket, (line) => diagnostics.push(line));
|
|
241
|
+
accepted.push(socket);
|
|
242
|
+
});
|
|
243
|
+
await new Promise<void>((resolve) => server.listen(socketPath, resolve));
|
|
244
|
+
|
|
245
|
+
// Record instead of dying, so the crash becomes an assertion we can attribute rather than
|
|
246
|
+
// a stack trace that kills the gate run itself.
|
|
247
|
+
const uncaught: Error[] = [];
|
|
248
|
+
const onUncaught = (err: Error) => uncaught.push(err);
|
|
249
|
+
process.on("uncaughtException", onUncaught);
|
|
250
|
+
// Every socket this cell opens, torn down in `finally` — `server.close()` only calls back
|
|
251
|
+
// once the LAST connection is gone, so a client left open on a failing assertion would turn
|
|
252
|
+
// a red gate into a hang (which is not a kill: a mutant must fail bounded, with its QK).
|
|
253
|
+
const opened: net.Socket[] = [];
|
|
254
|
+
try {
|
|
255
|
+
const client = net.createConnection(socketPath);
|
|
256
|
+
opened.push(client);
|
|
257
|
+
await new Promise<void>((resolve, reject) => {
|
|
258
|
+
client.once("connect", resolve);
|
|
259
|
+
client.once("error", reject);
|
|
260
|
+
});
|
|
261
|
+
// The sender gives up and hangs up — the C4 shape exactly.
|
|
262
|
+
client.on("error", () => {});
|
|
263
|
+
client.destroy();
|
|
264
|
+
|
|
265
|
+
// The receiver answers LATE, into a peer that is gone. Bounded: writes until the stream
|
|
266
|
+
// records an error, never longer.
|
|
267
|
+
for (let i = 0; i < 200 && accepted[0] !== undefined && accepted[0].errored === null; i++) {
|
|
268
|
+
try {
|
|
269
|
+
accepted[0].write(`${"x".repeat(64 * 1024)}\n`);
|
|
270
|
+
} catch {
|
|
271
|
+
// A synchronous ERR_STREAM_DESTROYED is the OTHER half — writeResponse's own
|
|
272
|
+
// try/catch owns it. This loop is hunting the asynchronous one.
|
|
273
|
+
}
|
|
274
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Fixture integrity FIRST: if no peer-disconnect error ever landed, the two cells below
|
|
278
|
+
// would be vacuously green no matter what the policy did.
|
|
279
|
+
const errored = accepted[0]?.errored as NodeJS.ErrnoException | null | undefined;
|
|
280
|
+
ok(
|
|
281
|
+
"7: fixture — a real peer disconnect produced an async EPIPE/ECONNRESET on the accepted socket",
|
|
282
|
+
errored != null && (errored.code === "EPIPE" || errored.code === "ECONNRESET"),
|
|
283
|
+
);
|
|
284
|
+
ok(
|
|
285
|
+
"7: [QK:CONTROL-SOCKET-NO-UNCAUGHT] a vanished peer never reaches the process as an uncaught exception " +
|
|
286
|
+
`(would have killed this resident session) — uncaught=${JSON.stringify(uncaught.map((e) => String(e)))}`,
|
|
287
|
+
uncaught.length === 0,
|
|
288
|
+
);
|
|
289
|
+
ok(
|
|
290
|
+
"7: [QK:CONTROL-SOCKET-ABSORBS-PEER-DISCONNECT] a vanished peer is absorbed silently, never diagnosed " +
|
|
291
|
+
`(it describes the client, not our state) — diagnostics=${JSON.stringify(diagnostics)}`,
|
|
292
|
+
diagnostics.length === 0,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// Unexpected code on a REAL accepted socket: diagnosed exactly once, with code AND
|
|
296
|
+
// message, and the emit must not throw back out of the event callback.
|
|
297
|
+
const client2 = net.createConnection(socketPath);
|
|
298
|
+
opened.push(client2);
|
|
299
|
+
await new Promise<void>((resolve, reject) => {
|
|
300
|
+
client2.once("connect", resolve);
|
|
301
|
+
client2.once("error", reject);
|
|
302
|
+
});
|
|
303
|
+
client2.on("error", () => {});
|
|
304
|
+
// The SECOND accepted connection — live, never disconnected, so the diagnosis path is
|
|
305
|
+
// exercised on a socket in ordinary service rather than on the already-errored one.
|
|
306
|
+
for (let i = 0; i < 200 && accepted[1] === undefined; i++) {
|
|
307
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
308
|
+
}
|
|
309
|
+
const live = accepted[1];
|
|
310
|
+
ok("7: fixture — the second connection was accepted and carries the policy", live !== undefined);
|
|
311
|
+
const unexpected: NodeJS.ErrnoException = Object.assign(new Error("no space left on device"), {
|
|
312
|
+
code: "ENOSPC",
|
|
313
|
+
});
|
|
314
|
+
const before = diagnostics.length;
|
|
315
|
+
let threw = false;
|
|
316
|
+
try {
|
|
317
|
+
live?.emit("error", unexpected);
|
|
318
|
+
} catch {
|
|
319
|
+
threw = true;
|
|
320
|
+
}
|
|
321
|
+
const emitted = diagnostics.slice(before);
|
|
322
|
+
ok(
|
|
323
|
+
"7: [QK:CONTROL-SOCKET-DIAGNOSES-UNEXPECTED] a non-disconnect error diagnoses EXACTLY once with code and " +
|
|
324
|
+
`message, and never rethrows from the event callback — threw=${threw} emitted=${JSON.stringify(emitted)}`,
|
|
325
|
+
threw === false &&
|
|
326
|
+
emitted.length === 1 &&
|
|
327
|
+
emitted[0]!.includes("ENOSPC") &&
|
|
328
|
+
emitted[0]!.includes("no space left on device"),
|
|
329
|
+
);
|
|
330
|
+
} finally {
|
|
331
|
+
process.off("uncaughtException", onUncaught);
|
|
332
|
+
for (const socket of [...opened, ...accepted]) socket.destroy();
|
|
333
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
334
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── 8. the surface installs the policy FIRST on every accepted connection ───
|
|
339
|
+
// Ordering is the contract, not merely presence: a listener attached after setEncoding or
|
|
340
|
+
// after the data handler leaves a window in which the very first write can still kill us.
|
|
341
|
+
{
|
|
342
|
+
const src = await fs.readFile(CONTROL_SRC, "utf8");
|
|
343
|
+
const accept = src.slice(src.indexOf("const server = net.createServer((socket) => {"));
|
|
344
|
+
const attachAt = accept.indexOf("attachAcceptedSocketDisconnectPolicy(socket)");
|
|
345
|
+
const encodingAt = accept.indexOf('socket.setEncoding("utf8")');
|
|
346
|
+
const dataAt = accept.indexOf('socket.on("data"');
|
|
347
|
+
ok(
|
|
348
|
+
"8: [QK:CONTROL-SOCKET-POLICY-FIRST] createServer attaches the disconnect policy before setEncoding and " +
|
|
349
|
+
`before the data handler — attach=${attachAt} setEncoding=${encodingAt} data=${dataAt}`,
|
|
350
|
+
attachAt !== -1 && encodingAt !== -1 && dataAt !== -1 && attachAt < encodingAt && attachAt < dataAt,
|
|
351
|
+
);
|
|
352
|
+
ok(
|
|
353
|
+
"8: the surface consumes the shared policy and defines no second error listener of its own",
|
|
354
|
+
/from "\.\/lib\/entwurf-control-rpc\.js"/.test(src) && !/socket\.on\("error"/.test(src),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
220
358
|
console.log(`\ncheck-entwurf-control-rpc: ${passed} checks passed`);
|
|
221
359
|
}
|
|
222
360
|
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import assert from "node:assert/strict";
|
|
31
31
|
import * as path from "node:path";
|
|
32
32
|
import { type EntwurfFactsDeps, listEntwurfFacts } from "../pi-extensions/lib/entwurf-fact-provider.ts";
|
|
33
|
+
import { UNOBSERVED_PLACEMENT } from "../pi-extensions/lib/herdr-placement.ts";
|
|
33
34
|
import { type MetaCitizenBackend, serializeMetaIdentity } from "../pi-extensions/lib/meta-session.ts";
|
|
34
35
|
import { SOCKET_SUFFIX, type SocketDirEntry } from "../pi-extensions/lib/socket-discovery.ts";
|
|
35
36
|
import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
|
|
@@ -83,6 +84,7 @@ function deps(
|
|
|
83
84
|
observe: (identity) => ({
|
|
84
85
|
receiver: identity.backend === "claude-code" ? "inactive" : "n/a",
|
|
85
86
|
transcript: identity.gardenId === GID_CLAUDE ? "absent" : "exists",
|
|
87
|
+
placement: UNOBSERVED_PLACEMENT,
|
|
86
88
|
}),
|
|
87
89
|
// Kind-carrying entries, like the real bindings: the listing must be able to refuse a
|
|
88
90
|
// symlinked record without following it. `irregularMeta` names the ones that are not
|
|
@@ -186,7 +188,7 @@ async function main(): Promise<void> {
|
|
|
186
188
|
const all = deps(meta, {});
|
|
187
189
|
all.observe = (identity) => {
|
|
188
190
|
allObserved.push(identity.gardenId);
|
|
189
|
-
return { receiver: "active", transcript: "exists" };
|
|
191
|
+
return { receiver: "active", transcript: "exists", placement: UNOBSERVED_PLACEMENT };
|
|
190
192
|
};
|
|
191
193
|
await listEntwurfFacts(all);
|
|
192
194
|
ok("Q112: generic provider without a presentation limit observes all 40 citizens", allObserved.length === 40);
|
|
@@ -196,7 +198,7 @@ async function main(): Promise<void> {
|
|
|
196
198
|
d.observationLimit = 32;
|
|
197
199
|
d.observe = (identity) => {
|
|
198
200
|
observed.push(identity.gardenId);
|
|
199
|
-
return { receiver: "active", transcript: "exists" };
|
|
201
|
+
return { receiver: "active", transcript: "exists", placement: UNOBSERVED_PLACEMENT };
|
|
200
202
|
};
|
|
201
203
|
const r = await listEntwurfFacts(d);
|
|
202
204
|
ok("Q112: full machine payload retains all 40 certified citizens", r.facts.peers.length === 40);
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
isLivenessSupported,
|
|
37
37
|
LIVENESS_DOMAIN_BACKENDS,
|
|
38
38
|
} from "../pi-extensions/lib/entwurf-v2-contract.ts";
|
|
39
|
+
import { UNOBSERVED_PLACEMENT } from "../pi-extensions/lib/herdr-placement.ts";
|
|
39
40
|
import {
|
|
40
41
|
META_CITIZEN_BACKENDS,
|
|
41
42
|
type MetaCitizenBackend,
|
|
@@ -138,7 +139,7 @@ for (const backend of META_CITIZEN_BACKENDS) {
|
|
|
138
139
|
ok("passthrough recordUpdatedAt", fact.recordUpdatedAt === id.recordUpdatedAt);
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
// ── facts-only keyset: identity facts + liveness + the
|
|
142
|
+
// ── facts-only keyset: identity facts + liveness + the three observed axes, and
|
|
142
143
|
// still NO verb-routing / no transcript PATH ────────────────────────────────
|
|
143
144
|
{
|
|
144
145
|
const fact = resolvePeerFact(identity("pi"), "alive");
|
|
@@ -154,20 +155,25 @@ for (const backend of META_CITIZEN_BACKENDS) {
|
|
|
154
155
|
"recordUpdatedAt",
|
|
155
156
|
"receiver",
|
|
156
157
|
"transcript",
|
|
158
|
+
"placement",
|
|
157
159
|
].sort();
|
|
158
160
|
assert.deepStrictEqual(keys, expected, `PeerFact keyset drift: got ${keys.join(",")}`);
|
|
159
|
-
ok("facts-only keyset exact (identity facts + liveness + observed receiver/transcript)", true);
|
|
161
|
+
ok("facts-only keyset exact (identity facts + liveness + observed receiver/transcript/placement)", true);
|
|
160
162
|
|
|
161
163
|
// #101: an unmeasured row says so. `unobserved` is the default for a caller that
|
|
162
164
|
// injected no observer — never `none`/`absent`, which would be a fabricated fact.
|
|
163
165
|
ok(
|
|
164
|
-
"an observer-less composition reports
|
|
165
|
-
fact.receiver === "unobserved" && fact.transcript === "unobserved",
|
|
166
|
+
"an observer-less composition reports all three axes as unobserved (never a default fact)",
|
|
167
|
+
fact.receiver === "unobserved" && fact.transcript === "unobserved" && fact.placement.kind === "unobserved",
|
|
166
168
|
);
|
|
167
|
-
const observed = resolvePeerFact(identity("claude-code"), null, {
|
|
169
|
+
const observed = resolvePeerFact(identity("claude-code"), null, {
|
|
170
|
+
receiver: "inactive",
|
|
171
|
+
transcript: "absent",
|
|
172
|
+
placement: UNOBSERVED_PLACEMENT,
|
|
173
|
+
});
|
|
168
174
|
ok(
|
|
169
175
|
"an injected observation rides onto the fact verbatim",
|
|
170
|
-
observed.receiver === "inactive" && observed.transcript === "absent",
|
|
176
|
+
observed.receiver === "inactive" && observed.transcript === "absent" && observed.placement.kind === "unobserved",
|
|
171
177
|
);
|
|
172
178
|
|
|
173
179
|
const FORBIDDEN = [
|
|
@@ -33,6 +33,7 @@ import { type EntwurfFactsResult, recordLessSocketMessage } from "../pi-extensio
|
|
|
33
33
|
import type { PeerFact } from "../pi-extensions/lib/entwurf-facts.ts";
|
|
34
34
|
import { ENTWURF_PEERS_RENDER_LIMIT, renderEntwurfPeers } from "../pi-extensions/lib/entwurf-peers-render.ts";
|
|
35
35
|
import type { FactLiveness } from "../pi-extensions/lib/entwurf-v2-contract.ts";
|
|
36
|
+
import { UNOBSERVED_PLACEMENT } from "../pi-extensions/lib/herdr-placement.ts";
|
|
36
37
|
import type { MetaCitizenBackend } from "../pi-extensions/lib/meta-session.ts";
|
|
37
38
|
import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
|
|
38
39
|
|
|
@@ -51,7 +52,7 @@ function peer(
|
|
|
51
52
|
gardenId: string,
|
|
52
53
|
backend: MetaCitizenBackend,
|
|
53
54
|
liveness: FactLiveness,
|
|
54
|
-
observed: Partial<Pick<PeerFact, "receiver" | "transcript">> = {},
|
|
55
|
+
observed: Partial<Pick<PeerFact, "receiver" | "transcript" | "placement">> = {},
|
|
55
56
|
): PeerFact {
|
|
56
57
|
return {
|
|
57
58
|
gardenId,
|
|
@@ -64,6 +65,7 @@ function peer(
|
|
|
64
65
|
liveness,
|
|
65
66
|
receiver: observed.receiver ?? "n/a",
|
|
66
67
|
transcript: observed.transcript ?? "exists",
|
|
68
|
+
placement: observed.placement ?? UNOBSERVED_PLACEMENT,
|
|
67
69
|
};
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -44,6 +44,7 @@ import * as path from "node:path";
|
|
|
44
44
|
import { fileURLToPath } from "node:url";
|
|
45
45
|
import {
|
|
46
46
|
computeSelfAddressability,
|
|
47
|
+
decideUncitizenedNotice,
|
|
47
48
|
type SelfAddressabilityFacts,
|
|
48
49
|
type SocketState,
|
|
49
50
|
} from "../pi-extensions/lib/entwurf-self-address.ts";
|
|
@@ -512,4 +513,71 @@ ok(
|
|
|
512
513
|
!/import[^;]*from\s*"\.\/lib\/entwurf-self-address\.(js|ts)"/.test(nativeSrc),
|
|
513
514
|
);
|
|
514
515
|
|
|
516
|
+
// ── the other half of the same honesty: a session that is NOT a citizen says so ──
|
|
517
|
+
// This gate exists because a surface must not CLAIM an addressability it does not have. The mirror
|
|
518
|
+
// of that is a surface that has none and says nothing: `[관측: GLG, 날것 PC, 2026-09-17]` a herdr
|
|
519
|
+
// plugin install wires this extension at USER scope, so it loads in every pi on the host while
|
|
520
|
+
// citizenship stays argv-gated — a plain `pi` after a green install is silent, tool-less and
|
|
521
|
+
// indistinguishable from an install that did nothing.
|
|
522
|
+
//
|
|
523
|
+
// WHY THESE THREE CELLS AND NOT ONE REGEX. The first cut pinned "told once, on the UI only" with a
|
|
524
|
+
// source regex that only proved a call existed somewhere. `[측정 2026-09-17, 독립 검수
|
|
525
|
+
// claude-opus-5 + 재현]` two mutants walked through it green: moving the call into the CITIZEN
|
|
526
|
+
// branch, and deleting the once-latch — 51/51 both times. That was a false success, so the
|
|
527
|
+
// conditions moved into a pure decision the truth table below exhausts, and what is left at the
|
|
528
|
+
// call site is narrow enough for a source pin to actually cover.
|
|
529
|
+
{
|
|
530
|
+
const decide = (controlEnabled: boolean, hasUI: boolean, alreadyShown: boolean): boolean =>
|
|
531
|
+
decideUncitizenedNotice({ controlEnabled, hasUI, alreadyShown });
|
|
532
|
+
const table = {
|
|
533
|
+
citizenFirstUI: decide(true, true, false),
|
|
534
|
+
citizenNoUI: decide(true, false, false),
|
|
535
|
+
citizenShown: decide(true, true, true),
|
|
536
|
+
uncitizenedFirstUI: decide(false, true, false),
|
|
537
|
+
uncitizenedNoUI: decide(false, false, false),
|
|
538
|
+
uncitizenedShown: decide(false, true, true),
|
|
539
|
+
uncitizenedShownNoUI: decide(false, false, true),
|
|
540
|
+
};
|
|
541
|
+
ok(
|
|
542
|
+
"a citizen is never told it is not one, a session with no UI is never told anything, and the " +
|
|
543
|
+
`answer is true in exactly ONE of the eight states [QK:SELFADDR-UNCITIZENED-DECISION] (${JSON.stringify(table)})`,
|
|
544
|
+
table.uncitizenedFirstUI === true &&
|
|
545
|
+
table.citizenFirstUI === false &&
|
|
546
|
+
table.citizenNoUI === false &&
|
|
547
|
+
table.citizenShown === false &&
|
|
548
|
+
table.uncitizenedNoUI === false &&
|
|
549
|
+
table.uncitizenedShown === false &&
|
|
550
|
+
table.uncitizenedShownNoUI === false,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// The call site carries the REAL flag as a fact and is unconditional — there is no branch left for
|
|
555
|
+
// a mutant to move it into, and passing a constant would make the decision above unreachable.
|
|
556
|
+
const noticeBody = nativeSrc.slice(
|
|
557
|
+
nativeSrc.indexOf("async function noticeUncitizenedSession"),
|
|
558
|
+
nativeSrc.indexOf("function shouldRegisterControlTools"),
|
|
559
|
+
);
|
|
560
|
+
ok(
|
|
561
|
+
"pi-native: the not-a-citizen notice is decided from the REAL flag, on the UI only, never on stderr " +
|
|
562
|
+
"[QK:SELFADDR-UNCITIZENED-NOTICED]",
|
|
563
|
+
/const enabled = pi\.getFlag\(ENTWURF_FLAG\) === true;\n\t\tawait noticeUncitizenedSession\(ctx, enabled\);/.test(
|
|
564
|
+
nativeSrc,
|
|
565
|
+
) &&
|
|
566
|
+
/decideUncitizenedNotice\(\{ controlEnabled, hasUI: ctx\.hasUI, alreadyShown: uncitizenedNoticeShown \}\)/.test(
|
|
567
|
+
noticeBody,
|
|
568
|
+
) &&
|
|
569
|
+
/--entwurf-control/.test(noticeBody) &&
|
|
570
|
+
!/process\.stderr/.test(noticeBody),
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
// The latch is the difference between a hint and a nag: `refreshServer` runs again on session
|
|
574
|
+
// switches, so the line that records "already said it" is load-bearing, not bookkeeping.
|
|
575
|
+
ok(
|
|
576
|
+
"pi-native: the notice records that it fired, so a session switch does not repeat it " +
|
|
577
|
+
"[QK:SELFADDR-UNCITIZENED-ONCE]",
|
|
578
|
+
/let uncitizenedNoticeShown = false;/.test(nativeSrc) &&
|
|
579
|
+
/uncitizenedNoticeShown = true;/.test(noticeBody) &&
|
|
580
|
+
noticeBody.indexOf("uncitizenedNoticeShown = true;") < noticeBody.indexOf("ctx.ui.notify("),
|
|
581
|
+
);
|
|
582
|
+
|
|
515
583
|
console.log(`\ncheck-entwurf-self-address: ${passed} checks passed`);
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* (a pi-alive citizen → control-socket execute; acquireLock spy saw {dir: lockDir}).
|
|
10
10
|
* B. control `sendOverSocket` builds the RpcSendCommand (type/message/mode/wants_reply/
|
|
11
11
|
* sender) and maps response.success→outcome; the hand releases under `lockDir`.
|
|
12
|
+
* B2. #115 — the SAME closure carries `response.error` onto the RpcSendResult, so a
|
|
13
|
+
* completed-but-refused RPC reaches the caller as `rejected` WITH the receiver's
|
|
14
|
+
* named reason. Dropping that one field renders a reasonless reject, which is the
|
|
15
|
+
* field symptom; no other deterministic gate holds this wiring.
|
|
12
16
|
* D. the meta-mailbox hand enqueues onto the wired sessionsDir/mailboxDir.
|
|
13
17
|
* E. Q3 + Q5 — a dead control send re-resolves (claude-code citizen) to the mailbox and
|
|
14
18
|
* enqueues through the SAME sendViaMailbox instance (same enqueue spy) on the SAME dirs
|
|
@@ -60,6 +64,10 @@ const LOCK_DIR = "/fake/locks";
|
|
|
60
64
|
const SESSIONS_DIR = "/fake/sessions";
|
|
61
65
|
const MAILBOX_DIR = "/fake/mailbox";
|
|
62
66
|
const CONTROL_DIR = "/fake/ctl";
|
|
67
|
+
/** The named in-band refusal a real receiver answers with while it compacts (#115).
|
|
68
|
+
* It is a plain opaque string here on purpose: the production seam must carry WHATEVER
|
|
69
|
+
* reason the receiver named, not a vocabulary this gate recognises. */
|
|
70
|
+
const IN_BAND_REJECT_REASON = "compacting";
|
|
63
71
|
|
|
64
72
|
function identity(backend: MetaIdentity["backend"], gardenId = GID): MetaIdentity {
|
|
65
73
|
return {
|
|
@@ -184,7 +192,10 @@ function makeSpiedFactory(over: {
|
|
|
184
192
|
recordExists?: boolean;
|
|
185
193
|
inspectKind?: TargetSocketInspection["kind"];
|
|
186
194
|
probe?: "alive" | "dead" | "indeterminate";
|
|
187
|
-
|
|
195
|
+
/** `in-band-reject` (#115): the RPC COMPLETES and the receiver refuses in band
|
|
196
|
+
* (`{success:false,error:"compacting"}`) — distinct from `dead-throw`, which never
|
|
197
|
+
* reaches a response at all. It is the only arm that carries `response.error`. */
|
|
198
|
+
rpc?: "success" | "dead-throw" | "in-band-reject";
|
|
188
199
|
classifyDead?: boolean;
|
|
189
200
|
/** #50 C3 — a caller with no authoritative sender (senderProvider → undefined). */
|
|
190
201
|
noSender?: boolean;
|
|
@@ -267,6 +278,11 @@ function makeSpiedFactory(over: {
|
|
|
267
278
|
e.code = "ECONNREFUSED";
|
|
268
279
|
throw e;
|
|
269
280
|
}
|
|
281
|
+
if (over.rpc === "in-band-reject") {
|
|
282
|
+
return {
|
|
283
|
+
response: { type: "response", command: command.type, success: false, error: IN_BAND_REJECT_REASON },
|
|
284
|
+
};
|
|
285
|
+
}
|
|
270
286
|
return { response: { type: "response", command: command.type, success: true } };
|
|
271
287
|
},
|
|
272
288
|
enqueue: (o) => {
|
|
@@ -531,6 +547,31 @@ async function main(): Promise<void> {
|
|
|
531
547
|
);
|
|
532
548
|
}
|
|
533
549
|
|
|
550
|
+
// ── B2: #115 — the production seam CARRIES the receiver's named in-band reason ─
|
|
551
|
+
// B above proves the success half of the map. This is the other half, and it is the
|
|
552
|
+
// half the field symptom lived in: a completed RPC answering `{success:false,
|
|
553
|
+
// error:"compacting"}`. The reason has to cross THREE production hops the fake cannot
|
|
554
|
+
// short-circuit — `response.error` → `RpcSendResult.error` (the factory's own
|
|
555
|
+
// `sendOverSocket` closure) → `driveSend`'s `inBandRejected` → the
|
|
556
|
+
// `executeControlSocketSend` result — so deleting the factory's `error:` wiring alone
|
|
557
|
+
// strands the reason and this cell goes red. `outcome` alone is NOT the assertion:
|
|
558
|
+
// a bare `rejected` is exactly what the defect rendered.
|
|
559
|
+
{
|
|
560
|
+
const { deps, spies } = makeSpiedFactory({ rpc: "in-band-reject" });
|
|
561
|
+
const res = await deps.executor.sendControl(CONTROL_PLAN, lockClaim());
|
|
562
|
+
ok("B2: in-band {success:false} → outcome 'rejected' (not failed, not sent)", res.outcome === "rejected");
|
|
563
|
+
ok(
|
|
564
|
+
"B2: [QK:V2PROD-INBAND-ERROR-WIRED] response.error reaches the send result verbatim",
|
|
565
|
+
res.rejectReason === IN_BAND_REJECT_REASON,
|
|
566
|
+
);
|
|
567
|
+
ok("B2: an in-band reject is a COMPLETED rpc — the socket hand ran once", spies.rpc.length === 1);
|
|
568
|
+
ok(
|
|
569
|
+
"B2: a rejected send still releases under the wired lockDir",
|
|
570
|
+
spies.release.length === 1 && spies.release[0].dir === LOCK_DIR,
|
|
571
|
+
);
|
|
572
|
+
ok("B2: an in-band reject never falls back to the mailbox", spies.enqueue.length === 0);
|
|
573
|
+
}
|
|
574
|
+
|
|
534
575
|
// ── C2: #50 C3 — the dormant rail carries the caller edge (<sender_info>) ──
|
|
535
576
|
// ── D: meta-mailbox hand enqueues onto the wired dirs ─────────────────────
|
|
536
577
|
{
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 1. ack success → outcome `sent`, release ×1, deadFallback NOT called.
|
|
7
7
|
* 2. in-band reject (success:false) → outcome `rejected`, release ×1, NO fallback
|
|
8
|
-
* (deadFallback + mailbox NOT called — the receiver was reached and refused);
|
|
9
|
-
*
|
|
8
|
+
* (deadFallback + mailbox NOT called — the receiver was reached and refused); a
|
|
9
|
+
* supplied receiver error is carried verbatim as `rejectReason` (N3 boundary).
|
|
10
10
|
* 3. dead → re-resolve(control-socket) → success → `fallback-sent`, release ×1,
|
|
11
11
|
* deadFallback called EXACTLY once and UNDER the still-held lock (before release).
|
|
12
12
|
* 4. dead → re-resolve reject → `rejected`, release ×1, and the resolver's reason is
|
|
@@ -193,16 +193,24 @@ async function main(): Promise<void> {
|
|
|
193
193
|
|
|
194
194
|
// ── 2: in-band reject → rejected, release once, NO fallback ───────────────
|
|
195
195
|
{
|
|
196
|
-
const { result, trace } = await run({ firstSend: { result: { success: false, error: "
|
|
196
|
+
const { result, trace } = await run({ firstSend: { result: { success: false, error: "compacting" } } });
|
|
197
197
|
ok("in-band reject → rejected", result.outcome === "rejected");
|
|
198
198
|
ok("in-band reject → release ×1", trace.releases.length === 1);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
ok(
|
|
200
|
+
"[QK:V2SEND-INBAND-REJECT-REASON] in-band control reject carries its receiver error",
|
|
201
|
+
result.rejectReason === "compacting",
|
|
202
|
+
);
|
|
202
203
|
ok(
|
|
203
204
|
"in-band reject → no deadFallback, no mailbox",
|
|
204
205
|
trace.deadFallbackCalls === 0 && trace.mailboxSends.length === 0,
|
|
205
206
|
);
|
|
207
|
+
const unnamed = await run({ firstSend: { result: { success: false } } });
|
|
208
|
+
ok("in-band reject without an error does not invent rejectReason", unnamed.result.rejectReason === undefined);
|
|
209
|
+
const empty = await run({ firstSend: { result: { success: false, error: "" } } });
|
|
210
|
+
ok(
|
|
211
|
+
"in-band reject with an empty error does not expose an empty rejectReason",
|
|
212
|
+
empty.result.rejectReason === undefined,
|
|
213
|
+
);
|
|
206
214
|
}
|
|
207
215
|
|
|
208
216
|
// ── 3: dead → re-resolve(control) success → fallback-sent, fallback before release
|
|
@@ -223,6 +231,16 @@ async function main(): Promise<void> {
|
|
|
223
231
|
"dead → deadFallback UNDER held lock (before release)",
|
|
224
232
|
trace.order.indexOf("deadFallback") < trace.order.indexOf("releaseLock"),
|
|
225
233
|
);
|
|
234
|
+
|
|
235
|
+
const refused = await run({
|
|
236
|
+
firstSend: { throwCode: "ECONNREFUSED" },
|
|
237
|
+
deadFallback: { kind: "execute", plan: RERESOLVED_CONTROL_PLAN },
|
|
238
|
+
fallbackSend: { result: { success: false, error: "retry-refused" } },
|
|
239
|
+
});
|
|
240
|
+
ok(
|
|
241
|
+
"dead → re-resolve(control) in-band reject carries the retry receiver error",
|
|
242
|
+
refused.result.outcome === "rejected" && refused.result.rejectReason === "retry-refused",
|
|
243
|
+
);
|
|
226
244
|
}
|
|
227
245
|
|
|
228
246
|
// ── 4: dead → re-resolve reject → rejected ────────────────────────────────
|
|
@@ -296,9 +314,10 @@ async function main(): Promise<void> {
|
|
|
296
314
|
const refused = await run({
|
|
297
315
|
firstSend: { throwCode: "ENOENT" },
|
|
298
316
|
deadFallback: { kind: "execute", plan: MAILBOX_PLAN },
|
|
299
|
-
fallbackSend: { result: { success: false } },
|
|
317
|
+
fallbackSend: { result: { success: false, error: "mailbox-refused" } },
|
|
300
318
|
});
|
|
301
319
|
ok("dead → mailbox enqueue success:false → rejected", refused.result.outcome === "rejected");
|
|
320
|
+
ok("dead → mailbox enqueue reject carries its receiver error", refused.result.rejectReason === "mailbox-refused");
|
|
302
321
|
// No file was written, so there is nothing to name — never echo a dep's stray path.
|
|
303
322
|
ok("dead → rejected enqueue carries NO messagePath", refused.result.messagePath === undefined);
|
|
304
323
|
}
|