@junghanacs/entwurf 0.17.1 → 0.18.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 +2 -1
- package/CHANGELOG.md +248 -0
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +14 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +9 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +11 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +66 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +10 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +8 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +9 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +25 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +14 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +24 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +61 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +64 -4
- package/mcp/entwurf-bridge/src/index.ts +16 -8
- package/package.json +3 -2
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -1
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +13 -8
- package/pi-extensions/lib/entwurf-deliverability.ts +104 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +20 -6
- package/pi-extensions/lib/entwurf-facts.ts +52 -3
- package/pi-extensions/lib/entwurf-peer-observe.ts +77 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +12 -1
- package/pi-extensions/lib/entwurf-v2-decider.ts +26 -8
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +9 -2
- package/pi-extensions/lib/entwurf-v2-production.ts +32 -12
- package/pi-extensions/lib/entwurf-v2-runner.ts +23 -6
- package/pi-extensions/lib/entwurf-v2-send.ts +26 -3
- package/pi-extensions/lib/entwurf-v2-surface.ts +24 -3
- package/pi-extensions/lib/meta-session.ts +76 -7
- package/pi-extensions/meta-bridge-hook.ts +72 -3
- package/run.sh +21 -0
- package/scripts/check-bridge-delivery.ts +42 -1
- package/scripts/check-entwurf-fact-provider.ts +22 -1
- package/scripts/check-entwurf-facts.ts +17 -2
- package/scripts/check-entwurf-peers-surface.ts +20 -2
- package/scripts/check-entwurf-self-address.ts +20 -2
- package/scripts/check-entwurf-v2-decider.ts +6 -3
- package/scripts/check-entwurf-v2-mailbox.ts +33 -0
- package/scripts/check-entwurf-v2-matrix.ts +10 -5
- package/scripts/check-entwurf-v2-production.ts +72 -1
- package/scripts/check-entwurf-v2-runner.ts +18 -0
- package/scripts/check-entwurf-v2-send.ts +13 -1
- package/scripts/check-entwurf-v2-surface.ts +75 -3
- package/scripts/check-gate-qualification.ts +1 -0
- package/scripts/check-hook-launch-topology.ts +70 -1
- package/scripts/check-mailbox-receipt-state.ts +6 -0
- package/scripts/check-meta-doctor-oracle.sh +73 -0
- package/scripts/check-meta-hook-session-switch.ts +560 -0
- package/scripts/check-meta-mailbox-state-write.ts +9 -2
- package/scripts/check-meta-manifest-schema.py +19 -1
- package/scripts/check-meta-session.ts +10 -2
- package/scripts/meta-bridge-doctor.sh +47 -7
- package/scripts/meta-bridge-state.py +23 -5
- package/scripts/meta-bridge-statusline.sh +65 -2
- package/scripts/mutants/meta-hook-session-switch.json +223 -0
- package/scripts/raw-async-delivery/README.md +280 -9
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +6 -2
- package/scripts/raw-async-delivery/lab-statusline.sh +63 -0
- package/scripts/raw-async-delivery/mailbox-watch.py +230 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +3 -1
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +13 -4
- package/scripts/raw-async-delivery/probe-delivery-transparency.sh +387 -0
- package/scripts/raw-claude-session-switch/README.md +170 -0
- package/scripts/smoke-acp-cortex-live.ts +22 -2
- package/scripts/smoke-acp-v2-send-live.ts +23 -2
- package/scripts/smoke-entwurf-chain-live.ts +85 -13
- package/scripts/smoke-entwurf-v2-matrix-live.ts +23 -1
- package/scripts/smoke-meta-async-drift.sh +31 -8
- package/scripts/smoke-meta-install-state.sh +170 -11
- package/scripts/smoke-meta-keyset-guard.sh +4 -1
- package/scripts/smoke-mux-fresh-call-live.ts +13 -3
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* surface never needs to name the `EntwurfV2RunResult` union (it only sees `{text,isError}`).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { basename } from "node:path";
|
|
22
23
|
import type { SenderEnvelope } from "./entwurf-control-rpc.ts";
|
|
23
24
|
import type { DispatchInput, EntwurfV2Mode } from "./entwurf-v2-decider.ts";
|
|
24
25
|
import { makeProductionEntwurfV2Deps, type ProductionEntwurfV2Opts } from "./entwurf-v2-production.ts";
|
|
@@ -137,6 +138,12 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
|
|
|
137
138
|
`\n ${c.detail}` +
|
|
138
139
|
(c.holder ? `\n holder: pid ${c.holder.pid} on ${c.holder.hostname} since ${c.holder.createdAt}` : "");
|
|
139
140
|
}
|
|
141
|
+
// #101 갭 C: name WHICH receiver axis failed. "mailbox-undeliverable" alone sent a
|
|
142
|
+
// caller looking for a dead session when the session was alive and had simply
|
|
143
|
+
// switched to another garden — the predicate knew that and the surface threw it away.
|
|
144
|
+
if (result.diagnostic?.kind === "mailbox-undeliverable") {
|
|
145
|
+
text += `\n mailbox-undeliverable: ${result.diagnostic.reason}`;
|
|
146
|
+
}
|
|
140
147
|
return { text, isError: true };
|
|
141
148
|
}
|
|
142
149
|
case "executed": {
|
|
@@ -144,8 +151,13 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
|
|
|
144
151
|
if (o.transport === "control-socket") {
|
|
145
152
|
const delivered = o.outcome === "sent" || o.outcome === "fallback-sent";
|
|
146
153
|
const reason = o.rejectReason ? ` (reason: ${o.rejectReason})` : "";
|
|
154
|
+
// #98 R, fallback leg: a dead socket that re-resolved to the mailbox wrote a
|
|
155
|
+
// `.msg` — name it, exactly as the primary mailbox rail does. Absent on a
|
|
156
|
+
// socket-to-socket retry (no file) and on every non-mailbox outcome, so the
|
|
157
|
+
// line degrades to the bare outcome rather than printing "undefined".
|
|
158
|
+
const enqueued = o.messagePath ? ` (enqueued ${basename(o.messagePath)})` : "";
|
|
147
159
|
return {
|
|
148
|
-
text: `entwurf_v2 control-socket → ${o.outcome}${reason}`,
|
|
160
|
+
text: `entwurf_v2 control-socket → ${o.outcome}${reason}${enqueued}`,
|
|
149
161
|
isError: !delivered,
|
|
150
162
|
};
|
|
151
163
|
}
|
|
@@ -156,8 +168,17 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
|
|
|
156
168
|
isError: false,
|
|
157
169
|
};
|
|
158
170
|
}
|
|
159
|
-
// meta-mailbox
|
|
160
|
-
|
|
171
|
+
// meta-mailbox. #98 R: name the FILE that was enqueued, so the sender's transcript
|
|
172
|
+
// carries a per-message identifier instead of a bare literal. Only the basename —
|
|
173
|
+
// the directory is `<meta-mailbox>/<target garden id>/`, which the caller already
|
|
174
|
+
// typed. Deliberately NOT a read stamp: at enqueue time `lastReadAt` belongs to the
|
|
175
|
+
// PREVIOUS message, so printing it would claim a read that has not happened.
|
|
176
|
+
// A dep that omits the receipt falls back to the old literal rather than printing
|
|
177
|
+
// "undefined" — the delivery still happened.
|
|
178
|
+
return {
|
|
179
|
+
text: `entwurf_v2 meta-mailbox → enqueued${o.messagePath ? ` (${basename(o.messagePath)})` : ""}`,
|
|
180
|
+
isError: false,
|
|
181
|
+
};
|
|
161
182
|
}
|
|
162
183
|
case "execution-failed": {
|
|
163
184
|
if (result.releaseFailed && result.finalizedOutcome) {
|
|
@@ -2017,6 +2017,50 @@ export function readMetaReceiverMarker(opts: ReadMetaReceiverMarkerOptions): Met
|
|
|
2017
2017
|
}
|
|
2018
2018
|
}
|
|
2019
2019
|
|
|
2020
|
+
export interface RemoveMetaReceiverMarkerOptions {
|
|
2021
|
+
gardenId: string;
|
|
2022
|
+
/** Only remove a marker this pid owns — a retirement must never reach another process's watch. */
|
|
2023
|
+
ownerPid: number;
|
|
2024
|
+
receiversDir?: string;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/**
|
|
2028
|
+
* Retire a receiver presence marker THIS owner pid wrote (#101 결함 A).
|
|
2029
|
+
*
|
|
2030
|
+
* A native process that switches sessions in place — an in-session `/resume` or `/clear`
|
|
2031
|
+
* fires a second SessionStart inside the same pid under a new native session id — leaves
|
|
2032
|
+
* the previous garden's marker behind, naming a live owner whose watch is gone. Measured
|
|
2033
|
+
* on oracle 2026-09-04: one pid (143742) held two gardens' markers four seconds apart, and
|
|
2034
|
+
* mail sent to the retired one sat unread.
|
|
2035
|
+
*
|
|
2036
|
+
* ONLY THE MARKER. The meta-record stays: records are identity, and deleting one
|
|
2037
|
+
* outside `meta-bridge-fresh-cut` would fight store certification (Hard Rule 7/8).
|
|
2038
|
+
* A retired citizen keeps its record, its transcript and its listing — it loses only
|
|
2039
|
+
* the claim that a doorbell is armed for it, which is exactly the claim that stopped
|
|
2040
|
+
* being true.
|
|
2041
|
+
*
|
|
2042
|
+
* Fail-closed the other way too: the marker is read back first and removed ONLY when its
|
|
2043
|
+
* `ownerPid` field equals the one passed here — pid equality, which is what a caller can
|
|
2044
|
+
* actually check; it does not prove the same PROCESS wrote it (a reused pid compares equal),
|
|
2045
|
+
* and the reader-side start-key guard is where that finer distinction lives. It is enough for
|
|
2046
|
+
* the rule this serves: a hook never retires a watch some other pid is holding. Returns whether a marker was actually removed. Never throws — a retirement
|
|
2047
|
+
* that cannot happen is a log line, not a broken session start.
|
|
2048
|
+
*/
|
|
2049
|
+
export function removeMetaReceiverMarker(opts: RemoveMetaReceiverMarkerOptions): boolean {
|
|
2050
|
+
try {
|
|
2051
|
+
const receiversDir = opts.receiversDir ?? defaultMetaReceiversDir();
|
|
2052
|
+
const file = metaReceiverMarkerPath(opts.gardenId, receiversDir);
|
|
2053
|
+
// verifyOwner:false — the point is the OWNERSHIP field, not the owner's liveness;
|
|
2054
|
+
// a marker whose owner already died is still this pid's to clean up when it names it.
|
|
2055
|
+
const marker = readMetaReceiverMarker({ markerPath: file, verifyOwner: false });
|
|
2056
|
+
if (!marker || marker.ownerPid !== opts.ownerPid) return false;
|
|
2057
|
+
fs.unlinkSync(file);
|
|
2058
|
+
return true;
|
|
2059
|
+
} catch {
|
|
2060
|
+
return false;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2020
2064
|
export interface UpsertMetaSessionOptions {
|
|
2021
2065
|
input: MetaIdentityMintInput;
|
|
2022
2066
|
/** Override the store directory (defaults to {@link defaultMetaSessionsDir}). */
|
|
@@ -2532,9 +2576,17 @@ export interface ReadMetaInboxResult {
|
|
|
2532
2576
|
* Drain a garden citizen's mailbox: read every unread message (a fresh `.msg`
|
|
2533
2577
|
* read before its doorbell, or a doorbell-rung `.msg.delivered`), archive each to
|
|
2534
2578
|
* `*.read` so a re-read never double-returns, and — only if at least one message
|
|
2535
|
-
* was read — stamp `lastReadAt
|
|
2536
|
-
*
|
|
2537
|
-
*
|
|
2579
|
+
* was read — stamp `lastReadAt`. `lastDeliveredAt` stays untouched (see the
|
|
2580
|
+
* stamp-site note below). An empty inbox mutates nothing: reading nothing is not
|
|
2581
|
+
* a receipt.
|
|
2582
|
+
*
|
|
2583
|
+
* WHERE THE PER-MESSAGE TRUTH LIVES (#98 5a, corrected 2026-09-03). The FILE SUFFIX
|
|
2584
|
+
* is the per-message receipt: `.msg` = enqueued, `.msg.delivered` = the doorbell
|
|
2585
|
+
* rang for it, `.msg.delivered.read` = this function handed it to the reader.
|
|
2586
|
+
* `state.json` is NOT that — its three slots are GARDEN-WIDE and overwritten, so
|
|
2587
|
+
* `lastReadAt` says "this citizen last read something at T", never "message X was
|
|
2588
|
+
* read". A sender that quotes `lastReadAt` back as the fate of the letter it just
|
|
2589
|
+
* queued is quoting the PREVIOUS letter's read. That misreading is what opened #98.
|
|
2538
2590
|
*/
|
|
2539
2591
|
export function readMetaInbox(opts: ReadMetaInboxOptions): ReadMetaInboxResult {
|
|
2540
2592
|
const now = opts.now ?? new Date();
|
|
@@ -2560,10 +2612,18 @@ export function readMetaInbox(opts: ReadMetaInboxOptions): ReadMetaInboxResult {
|
|
|
2560
2612
|
return { gardenId: citizen.gardenId, messages, readAt: null, recordPath: recordFile };
|
|
2561
2613
|
}
|
|
2562
2614
|
|
|
2563
|
-
// 3D-4 the cut: the read receipt lives SOLELY in the mailbox state store
|
|
2564
|
-
// Stamp lastReadAt — the one
|
|
2565
|
-
// reached the reader). lastDeliveredAt is
|
|
2566
|
-
//
|
|
2615
|
+
// 3D-4 the cut: the garden-wide read receipt lives SOLELY in the mailbox state store
|
|
2616
|
+
// now. Stamp lastReadAt — the one slot this layer stamps honestly (it KNOWS a body
|
|
2617
|
+
// reached the reader at this instant). lastDeliveredAt is left alone, and #98 5a
|
|
2618
|
+
// corrects WHY: the old comment said "the doorbell owns it", but the shipped
|
|
2619
|
+
// doorbell.sh (54 lines) writes NOTHING to state.json — measured, zero state writes.
|
|
2620
|
+
// So `lastDeliveredAt` is a RESERVED SLOT nobody stamps, permanently null on all
|
|
2621
|
+
// ~180 on-disk states, while 900+ files carry a `.delivered` suffix. Stamping it
|
|
2622
|
+
// here would still be wrong (it would report read-time as delivery-time), so it
|
|
2623
|
+
// stays null — but do not read "the doorbell will fill it in" into that. The
|
|
2624
|
+
// per-message delivery fact is the `.delivered` SUFFIX. The field is left in place
|
|
2625
|
+
// deliberately: removing it is a migration (the parser rejects unknown keys AND a
|
|
2626
|
+
// bumped schemaVersion, and every stamp re-parses first), tracked as #98 5b.
|
|
2567
2627
|
// The state stamp returns the updated state, whose lastReadAt IS the D7 read-receipt.
|
|
2568
2628
|
// Inside the messages.length>0 branch by construction — an empty inbox already
|
|
2569
2629
|
// early-returned (no .read archive, state untouched), so "read nothing" is no
|
|
@@ -2603,6 +2663,15 @@ export const MAILBOX_RECEIPT_SCHEMA_VERSION = 1 as const;
|
|
|
2603
2663
|
* The per-citizen mailbox receipt state. Holds exactly the three delivery
|
|
2604
2664
|
* timestamps that move out of `record.delivery` (wakeMode/deliveryLevel are
|
|
2605
2665
|
* capability, deliberately absent). Body is SSOT; the on-disk path is derived.
|
|
2666
|
+
*
|
|
2667
|
+
* SCOPE (#98 5a): these are GARDEN-WIDE "last activity" slots, single-valued and
|
|
2668
|
+
* overwritten — NOT per-message receipts. Per message, the receipt is the file
|
|
2669
|
+
* suffix in the same directory (`.msg` → `.msg.delivered` → `.msg.delivered.read`).
|
|
2670
|
+
* `lastEnqueuedAt`/`lastReadAt` are stamped by `enqueueMetaMessage`/`readMetaInbox`.
|
|
2671
|
+
* `lastDeliveredAt` is stamped by NOBODY: the doorbell does not write state.json, so
|
|
2672
|
+
* it is a reserved slot that is null everywhere. Kept rather than removed because the
|
|
2673
|
+
* parser is doubly strict (exact schemaVersion + no unknown keys) and every stamp
|
|
2674
|
+
* re-parses, so dropping it is a migration over the existing on-disk v1 files (#98 5b).
|
|
2606
2675
|
*/
|
|
2607
2676
|
export interface MailboxReceiptState {
|
|
2608
2677
|
schemaVersion: typeof MAILBOX_RECEIPT_SCHEMA_VERSION;
|
|
@@ -28,6 +28,20 @@
|
|
|
28
28
|
* a UserPromptSubmit fire does a degraded RECORD backfill (upsert) but cannot
|
|
29
29
|
* re-arm the idle watch — the record's address is restored, the wake is not.
|
|
30
30
|
*
|
|
31
|
+
* SESSION SWITCH (#101). One Claude process serves one session at a time, but it can
|
|
32
|
+
* change which. Measured on oracle 2026-09-04 (raw lab S1-S6, Claude Code 2.1.260): a bare
|
|
33
|
+
* `claude` mints a NEW session (`source=startup`), and an in-session `/resume` or `/clear`
|
|
34
|
+
* then fires a SECOND SessionStart under the SAME pid for a DIFFERENT native id
|
|
35
|
+
* (`source=resume` / `source=clear`). The first garden is left behind — in the #101 field
|
|
36
|
+
* case its transcript was never written at all — while its receiver marker still names a
|
|
37
|
+
* LIVE owner, so a sender reads an armed doorbell nobody holds. This hook therefore reads
|
|
38
|
+
* the sender marker BEFORE overwriting it and retires the previous garden's receiver marker
|
|
39
|
+
* (marker only — records are identity and are never deleted here; and only a marker this
|
|
40
|
+
* pid owns). Compaction (`source=compact`) re-fires SessionStart for the SAME native id, so
|
|
41
|
+
* the same rule retires nothing there — measured, not assumed. The envelope's `source` is
|
|
42
|
+
* logged beside all of it and decides nothing: the switch is settled by what is on disk,
|
|
43
|
+
* which holds on every host and vendor version.
|
|
44
|
+
*
|
|
31
45
|
* LAUNCH: never invoked directly by Claude. `hooks.json` declares the EXEC form
|
|
32
46
|
* (`command` = `<plugin-root>/scripts/hook-launch.sh`, `args` = [node, this file]),
|
|
33
47
|
* and the launcher `exec`s that argv — so this process inherits the launcher's pid
|
|
@@ -51,6 +65,8 @@ import {
|
|
|
51
65
|
defaultMetaSessionsDir,
|
|
52
66
|
isPlausibleOwnerPid,
|
|
53
67
|
type MetaReceiverArmProvenance,
|
|
68
|
+
readMetaSenderMarker,
|
|
69
|
+
removeMetaReceiverMarker,
|
|
54
70
|
upsertMetaSession,
|
|
55
71
|
writeMetaReceiverMarker,
|
|
56
72
|
writeMetaSenderMarker,
|
|
@@ -191,6 +207,13 @@ function main(): void {
|
|
|
191
207
|
? env.model_id
|
|
192
208
|
: undefined;
|
|
193
209
|
const eventName = typeof env.hook_event_name === "string" ? env.hook_event_name : "SessionStart";
|
|
210
|
+
// `source` (startup | resume | clear | compact) is Claude's own word for WHY this
|
|
211
|
+
// SessionStart fired. It is logged on every line below and decides nothing: a session
|
|
212
|
+
// switch is settled by what is on disk (the sender marker's garden), which is true on
|
|
213
|
+
// every host and every vendor version. Logging it is how this host finally gets a
|
|
214
|
+
// receipt for the envelope order the #101 diagnosis could only read from vendor docs —
|
|
215
|
+
// and the raw lab (scripts/raw-claude-session-switch) reads these lines, not a guess.
|
|
216
|
+
const source = typeof env.source === "string" && env.source.length > 0 ? env.source : "(unset)";
|
|
194
217
|
|
|
195
218
|
if (!sessionId || !transcriptPath) {
|
|
196
219
|
// A degraded envelope: cannot mint an honest reference record. Log + no-op
|
|
@@ -213,7 +236,10 @@ function main(): void {
|
|
|
213
236
|
input: { backend: "claude-code", nativeSessionId: sessionId, transcriptPath, cwd, model },
|
|
214
237
|
});
|
|
215
238
|
gardenId = result.record.gardenId;
|
|
216
|
-
logLine(
|
|
239
|
+
logLine(
|
|
240
|
+
"INFO",
|
|
241
|
+
`${result.action} record ${path.basename(result.path)} (event=${eventName}, source=${source}, native=${sessionId})`,
|
|
242
|
+
);
|
|
217
243
|
} catch (err) {
|
|
218
244
|
// Best-effort: a broken record store must surface via the doctor, not by
|
|
219
245
|
// breaking the user's session open. Log and continue with no arm. This is
|
|
@@ -241,9 +267,52 @@ function main(): void {
|
|
|
241
267
|
// an env var. Missing launcher provenance or an implausible parent yields no marker.
|
|
242
268
|
const ownerPid = resolveMetaHookOwnerPid();
|
|
243
269
|
if (ownerPid !== null) {
|
|
270
|
+
// SESSION SWITCH RETIREMENT (#101 결함 A). One Claude process serves ONE session at a
|
|
271
|
+
// time, but it can switch which: an in-session `/resume` or `/clear` fires a second
|
|
272
|
+
// SessionStart under the same pid for a different native id, leaving the session the
|
|
273
|
+
// process started with behind (measured on oracle, meta-bridge-hook.log 2026-09-04
|
|
274
|
+
// 13:13:04 `source=startup` → 13:13:37 `source=resume`; the field case at 09:31:35 →
|
|
275
|
+
// 09:31:39 is the same shape). Whatever it was serving before is no longer being
|
|
276
|
+
// drained, so the marker advertising its doorbell has to go.
|
|
277
|
+
//
|
|
278
|
+
// The evidence is the sender marker as it stands RIGHT NOW — pid → the garden this
|
|
279
|
+
// process serves — which is why this reads it BEFORE the write below overwrites it with
|
|
280
|
+
// the new garden. No vendor field is consulted: `source` is logged, not branched on, so
|
|
281
|
+
// a host or version that words it differently changes nothing here. A same-garden
|
|
282
|
+
// re-registration (every UserPromptSubmit, a CwdChanged, a re-fired SessionStart) finds
|
|
283
|
+
// an equal garden id and retires NOTHING — the marker it would remove is the live one.
|
|
284
|
+
//
|
|
285
|
+
// Only the marker, never the record (see removeMetaReceiverMarker), and only a marker
|
|
286
|
+
// this pid owns.
|
|
287
|
+
//
|
|
288
|
+
// AND ONLY ON AN EVENT THAT CAN ARM THE REPLACEMENT (cross-review, 2026-09-04). This
|
|
289
|
+
// block sits before the UserPromptSubmit early-return, and UPS cannot emit watchPaths —
|
|
290
|
+
// so a retirement reached from there would take a doorbell down with nothing in the same
|
|
291
|
+
// run able to put one back. A watch is retired only by a run that arms one; that is the
|
|
292
|
+
// whole rule. `armProvenanceFor` is the same predicate the arm block below uses, so the
|
|
293
|
+
// two can never disagree about which events those are.
|
|
294
|
+
//
|
|
295
|
+
// It is NOT a defence against a keystroke from a session this pid has left: measured on
|
|
296
|
+
// oracle 2026-09-04 (raw lab S1-S6, meta-bridge-hook.log), every UserPromptSubmit named
|
|
297
|
+
// the native id its own pid's preceding SessionStart had established — 8 of 8, none
|
|
298
|
+
// otherwise. The hook runs synchronously inside the session's own process, so a UPS
|
|
299
|
+
// envelope is that session speaking, and the sender pointer it moves is authoritative.
|
|
300
|
+
const previous =
|
|
301
|
+
armProvenanceFor(eventName) !== null
|
|
302
|
+
? readMetaSenderMarker({ backend: "claude-code", ownerPid, verifyOwner: false })
|
|
303
|
+
: null;
|
|
304
|
+
if (previous && previous.gardenId !== gardenId) {
|
|
305
|
+
const retired = removeMetaReceiverMarker({ gardenId: previous.gardenId, ownerPid });
|
|
306
|
+
logLine(
|
|
307
|
+
"INFO",
|
|
308
|
+
retired
|
|
309
|
+
? `retired receiver marker ${previous.gardenId} — owner pid ${ownerPid} switched to ${gardenId} (event=${eventName}, source=${source})`
|
|
310
|
+
: `no receiver marker to retire for ${previous.gardenId} — owner pid ${ownerPid} switched to ${gardenId} (event=${eventName}, source=${source})`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
244
313
|
try {
|
|
245
314
|
writeMetaSenderMarker({ backend: "claude-code", gardenId, nativeSessionId: sessionId, cwd, ownerPid });
|
|
246
|
-
logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName})`);
|
|
315
|
+
logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName}, source=${source})`);
|
|
247
316
|
} catch (err) {
|
|
248
317
|
logLine(
|
|
249
318
|
"WARN",
|
|
@@ -294,7 +363,7 @@ function main(): void {
|
|
|
294
363
|
ownerPid,
|
|
295
364
|
armProvenance,
|
|
296
365
|
});
|
|
297
|
-
logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName}`);
|
|
366
|
+
logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName} source=${source}`);
|
|
298
367
|
} catch (err) {
|
|
299
368
|
logLine(
|
|
300
369
|
"WARN",
|
package/run.sh
CHANGED
|
@@ -144,6 +144,7 @@ Usage:
|
|
|
144
144
|
./run.sh check-meta-mailbox-state-write # deterministic gate (0.11 Stage 0 step 3D-4 commit2): post-cut receipt is state-only — meta-record file byte-identical across enqueue/read, state carries lastEnqueuedAt/lastReadAt (field isolation), empty inbox no-op on record+state, drift surfaces; no API
|
|
145
145
|
./run.sh check-meta-receiver-marker # deterministic gate (SE-2): receiver marker round-trip/start-key/provenance, UserPromptSubmit cannot mint presence, reader does not gate on record existence — marker SEMANTICS only; launch topology moved to check-hook-launch-topology
|
|
146
146
|
./run.sh check-hook-launch-topology # #51 gate 1: shipped hooks.json is exec form through hook-launch.sh, launcher is loud on an empty argv (older Claude's silent args drop), exec preserves the pid so the hook's parent is Claude, and a space/$/backtick plugin path survives as one argv element
|
|
147
|
+
./run.sh check-meta-hook-session-switch # #101 gate: ONE Claude pid, TWO SessionStarts (resume picker placeholder -> resumed id). The hook retires the marker of the garden it stopped serving (records untouched, same-garden re-registration retires nothing), the reader stays fail-closed on a re-planted stale marker (watchArmed measured against the owner's sender marker, never copied), production dispatch refuses the retired garden and NAMES the failing axis, and exactly one of the two gardens is deliverable. Sandboxed roots, no API
|
|
147
148
|
./run.sh check-meta-identity-consumers # deterministic gate: V3-only consumer seam — per-entry targeted read + addressable read snapshot uniqueness, non-regular rivals never read, drift/unparseable rivals unreachable, unreadable regular rivals fail loud; strict upsert refuses an unreadable store before any write, no API
|
|
148
149
|
./run.sh check-meta-capability-source # deterministic gate (0.11 Stage 0 step 3D-3): capability-source cut-over — mint/parse read wakeMode/deliveryLevel from the registry (metaCapabilityFor, registry-driven via injection), not META_BACKEND_DESCRIPTORS; behaviour-preserving (registry ≡ const); the record.delivery slot 3D-3 preserved was deleted by 3D-4, no API
|
|
149
150
|
./run.sh check-socket-probe # deterministic gate (0.11 Stage 0, F3): three-valued control-socket liveness (alive|dead|indeterminate) — GC reclaims dead only, indeterminate survives; pure classify + 2-socket integration, no API
|
|
@@ -894,6 +895,23 @@ check_meta_receiver_marker() {
|
|
|
894
895
|
run_ts scripts/check-meta-receiver-marker.ts
|
|
895
896
|
}
|
|
896
897
|
|
|
898
|
+
check_meta_hook_session_switch() {
|
|
899
|
+
# #101 gate: a Claude Code SESSION SWITCH — one native process that stops serving one
|
|
900
|
+
# garden and starts serving another. The resume picker (and `/clear`) fire SessionStart
|
|
901
|
+
# twice inside one pid: once for the TUI's placeholder id, once for the id the operator
|
|
902
|
+
# picked. Before this gate nothing asked "one owner pid, two gardens", so a placeholder
|
|
903
|
+
# citizen stayed registered with an armed-looking doorbell and real mail rotted in it.
|
|
904
|
+
# Plays Claude the way check-hook-launch-topology does (spawn the SHIPPED launcher with
|
|
905
|
+
# the manifest's argv, so the hook's parent is this process = one fake owner pid) with
|
|
906
|
+
# every meta root sandboxed. Asserts: both records survive (marker retired, never the
|
|
907
|
+
# record); the retired garden's marker is gone; a same-garden re-registration retires
|
|
908
|
+
# NOTHING; a re-planted stale marker is still undeliverable (the sender-marker join,
|
|
909
|
+
# fail-closed at the reader); production dispatch refuses it and carries the predicate's
|
|
910
|
+
# reason to the rendered surface; exactly one garden is deliverable and it is the served
|
|
911
|
+
# one. Real tmpdir, no API.
|
|
912
|
+
run_ts scripts/check-meta-hook-session-switch.ts
|
|
913
|
+
}
|
|
914
|
+
|
|
897
915
|
check_copilot_birth_hook() {
|
|
898
916
|
# #82 gate: the Copilot BIRTH path, proven without Copilot. Drives the REAL
|
|
899
917
|
# assembler (--assemble-only, into a temp dir), then fires the baked launcher the
|
|
@@ -5700,6 +5718,9 @@ case "$cmd" in
|
|
|
5700
5718
|
check-meta-receiver-marker)
|
|
5701
5719
|
check_meta_receiver_marker
|
|
5702
5720
|
;;
|
|
5721
|
+
check-meta-hook-session-switch)
|
|
5722
|
+
check_meta_hook_session_switch
|
|
5723
|
+
;;
|
|
5703
5724
|
check-meta-identity-consumers)
|
|
5704
5725
|
check_meta_identity_consumers
|
|
5705
5726
|
;;
|
|
@@ -181,16 +181,40 @@ writeMetaReceiverMarker({
|
|
|
181
181
|
|
|
182
182
|
// RECEIVER — a different armed self-fetch citizen. A dead/unarmed target must remain
|
|
183
183
|
// mailbox-undeliverable, so this marker isolates artifact delivery from target liveness.
|
|
184
|
+
//
|
|
185
|
+
// It is owned by a DIFFERENT live pid than the sender, and that is not decoration (#101):
|
|
186
|
+
// a claude-code watch owner serves exactly one garden at a time, and deliverability now
|
|
187
|
+
// requires that owner's sender marker to still name the garden being addressed. Two
|
|
188
|
+
// citizens armed under ONE pid is a state the real world does not produce — it is what a
|
|
189
|
+
// session switch leaves behind — so a fixture that modelled it would be asking the
|
|
190
|
+
// artifact to deliver into a retired watch.
|
|
191
|
+
//
|
|
192
|
+
// The stand-in second native CLI is a spawned idle process, NOT this gate's parent: the
|
|
193
|
+
// subject bridge resolves its own sender identity through its ancestry, and an ancestor
|
|
194
|
+
// holding a second garden's marker is the "ambiguous sender identity" refusal the bridge
|
|
195
|
+
// already ships. A live pid outside that ancestry is what a real second CLI looks like.
|
|
196
|
+
// It exits on its own timer, and is killed in the outer finally.
|
|
197
|
+
const receiverOwner = spawn(process.execPath, ["-e", "setTimeout(() => {}, 300000)"], { stdio: "ignore" });
|
|
198
|
+
const receiverOwnerPid = receiverOwner.pid;
|
|
199
|
+
if (typeof receiverOwnerPid !== "number") throw new Error("could not spawn the stand-in receiver owner process");
|
|
184
200
|
const receiver = upsertMetaSession({
|
|
185
201
|
input: { backend: "claude-code", nativeSessionId: `bridge-delivery-receiver-${process.pid}`, cwd: tmp },
|
|
186
202
|
dir: sessionsDir,
|
|
187
203
|
});
|
|
188
204
|
const gid = receiver.record.gardenId;
|
|
205
|
+
writeMetaSenderMarker({
|
|
206
|
+
backend: "claude-code",
|
|
207
|
+
gardenId: gid,
|
|
208
|
+
nativeSessionId: receiver.record.nativeSessionId,
|
|
209
|
+
cwd: tmp,
|
|
210
|
+
ownerPid: receiverOwnerPid,
|
|
211
|
+
sendersDir,
|
|
212
|
+
});
|
|
189
213
|
writeMetaReceiverMarker({
|
|
190
214
|
gardenId: gid,
|
|
191
215
|
backend: "claude-code",
|
|
192
216
|
nativeSessionId: receiver.record.nativeSessionId,
|
|
193
|
-
ownerPid:
|
|
217
|
+
ownerPid: receiverOwnerPid,
|
|
194
218
|
armProvenance: "session-start",
|
|
195
219
|
receiversDir,
|
|
196
220
|
});
|
|
@@ -471,6 +495,20 @@ try {
|
|
|
471
495
|
await fsp.writeFile(receiverRecordFile, receiverRecordV3Bytes);
|
|
472
496
|
const anonSendersDir = path.join(tmp, "meta-senders-anon");
|
|
473
497
|
await fsp.mkdir(anonSendersDir, { recursive: true });
|
|
498
|
+
// The TARGET's owner marker still belongs in this dir (#101). Anonymity is about the
|
|
499
|
+
// CALLER — no marker under the bridge child's own owner pid — while deliverability asks
|
|
500
|
+
// a different question of a different pid: is the target's watch owner still serving the
|
|
501
|
+
// target's garden? Leaving it out would make every cell below fail as
|
|
502
|
+
// mailbox-undeliverable and prove nothing about the anonymous hatch. In production no
|
|
503
|
+
// one swaps this dir; the split exists only because this fixture does.
|
|
504
|
+
writeMetaSenderMarker({
|
|
505
|
+
backend: "claude-code",
|
|
506
|
+
gardenId: gid,
|
|
507
|
+
nativeSessionId: receiver.record.nativeSessionId,
|
|
508
|
+
cwd: tmp,
|
|
509
|
+
ownerPid: receiverOwnerPid,
|
|
510
|
+
sendersDir: anonSendersDir,
|
|
511
|
+
});
|
|
474
512
|
const oneShotV2 = async (
|
|
475
513
|
cellEnv: NodeJS.ProcessEnv,
|
|
476
514
|
message: string,
|
|
@@ -582,6 +620,9 @@ try {
|
|
|
582
620
|
try {
|
|
583
621
|
child?.kill("SIGTERM");
|
|
584
622
|
} catch {}
|
|
623
|
+
try {
|
|
624
|
+
receiverOwner.kill("SIGTERM");
|
|
625
|
+
} catch {}
|
|
585
626
|
await fsp.rm(tmp, { recursive: true, force: true });
|
|
586
627
|
}
|
|
587
628
|
|
|
@@ -19,7 +19,12 @@
|
|
|
19
19
|
* socket-dir-read-error diagnostic (P2e②),
|
|
20
20
|
* - diagnostics are kind-tagged and sorted.
|
|
21
21
|
*
|
|
22
|
-
* No IO — meta entries/reader and socket dir/readdir/probe are injected fakes
|
|
22
|
+
* No IO — meta entries/reader and socket dir/readdir/probe are injected fakes, and so is
|
|
23
|
+
* the #101 `observe` seam. That seam is the one dep whose PRODUCTION default is real IO
|
|
24
|
+
* (one default is safer than two wiring sites each passing their own observer), so a gate
|
|
25
|
+
* that left it out would quietly stat the operator's real garden roots for fabricated
|
|
26
|
+
* garden ids. It is injected here for that reason, and the cell below proves the injected
|
|
27
|
+
* observation actually reaches the fact rather than being dropped on the way.
|
|
23
28
|
*/
|
|
24
29
|
|
|
25
30
|
import assert from "node:assert/strict";
|
|
@@ -74,6 +79,11 @@ function deps(
|
|
|
74
79
|
): EntwurfFactsDeps {
|
|
75
80
|
const symlinkSet = new Set(opts.symlinks ?? []);
|
|
76
81
|
return {
|
|
82
|
+
// Injected observation (see the header): a per-identity fake, never the real IO.
|
|
83
|
+
observe: (identity) => ({
|
|
84
|
+
receiver: identity.backend === "claude-code" ? "inactive" : "n/a",
|
|
85
|
+
transcript: identity.gardenId === GID_CLAUDE ? "absent" : "exists",
|
|
86
|
+
}),
|
|
77
87
|
// Kind-carrying entries, like the real bindings: the listing must be able to refuse a
|
|
78
88
|
// symlinked record without following it. `irregularMeta` names the ones that are not
|
|
79
89
|
// regular files.
|
|
@@ -144,6 +154,17 @@ async function main(): Promise<void> {
|
|
|
144
154
|
);
|
|
145
155
|
ok("basic: record-less-socket diagnostic keyset exact", true);
|
|
146
156
|
}
|
|
157
|
+
// #101: the injected observation rides onto each peer. A provider that dropped the
|
|
158
|
+
// seam would return `unobserved` rows — visible, but useless to the caller the
|
|
159
|
+
// columns exist for.
|
|
160
|
+
{
|
|
161
|
+
const claude = r.facts.peers.find((p) => p.gardenId === GID_CLAUDE);
|
|
162
|
+
const pi = r.facts.peers.find((p) => p.gardenId === GID_PI);
|
|
163
|
+
ok(
|
|
164
|
+
"basic: the injected observation reaches every peer fact",
|
|
165
|
+
claude?.receiver === "inactive" && claude?.transcript === "absent" && pi?.receiver === "n/a",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
147
168
|
// A DEAD record-less socket groups under a different (stale) message — no fresh-cut
|
|
148
169
|
// pointer for a leftover file, so same-state sockets aggregate per liveness.
|
|
149
170
|
const r2 = await listEntwurfFacts(deps({}, { [GID_SOCKET_ONLY]: "dead" }));
|
|
@@ -138,7 +138,8 @@ for (const backend of META_CITIZEN_BACKENDS) {
|
|
|
138
138
|
ok("passthrough recordUpdatedAt", fact.recordUpdatedAt === id.recordUpdatedAt);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
// ── facts-only keyset: identity facts + liveness
|
|
141
|
+
// ── facts-only keyset: identity facts + liveness + the two observed axes, and
|
|
142
|
+
// still NO verb-routing / no transcript PATH ────────────────────────────────
|
|
142
143
|
{
|
|
143
144
|
const fact = resolvePeerFact(identity("pi"), "alive");
|
|
144
145
|
const keys = Object.keys(fact).sort();
|
|
@@ -151,9 +152,23 @@ for (const backend of META_CITIZEN_BACKENDS) {
|
|
|
151
152
|
"model",
|
|
152
153
|
"nativeSessionId",
|
|
153
154
|
"recordUpdatedAt",
|
|
155
|
+
"receiver",
|
|
156
|
+
"transcript",
|
|
154
157
|
].sort();
|
|
155
158
|
assert.deepStrictEqual(keys, expected, `PeerFact keyset drift: got ${keys.join(",")}`);
|
|
156
|
-
ok("facts-only keyset exact (identity facts + liveness)", true);
|
|
159
|
+
ok("facts-only keyset exact (identity facts + liveness + observed receiver/transcript)", true);
|
|
160
|
+
|
|
161
|
+
// #101: an unmeasured row says so. `unobserved` is the default for a caller that
|
|
162
|
+
// injected no observer — never `none`/`absent`, which would be a fabricated fact.
|
|
163
|
+
ok(
|
|
164
|
+
"an observer-less composition reports both axes as unobserved (never a default fact)",
|
|
165
|
+
fact.receiver === "unobserved" && fact.transcript === "unobserved",
|
|
166
|
+
);
|
|
167
|
+
const observed = resolvePeerFact(identity("claude-code"), null, { receiver: "inactive", transcript: "absent" });
|
|
168
|
+
ok(
|
|
169
|
+
"an injected observation rides onto the fact verbatim",
|
|
170
|
+
observed.receiver === "inactive" && observed.transcript === "absent",
|
|
171
|
+
);
|
|
157
172
|
|
|
158
173
|
const FORBIDDEN = [
|
|
159
174
|
"resumable",
|
|
@@ -47,7 +47,12 @@ const FORBIDDEN = ["sendable", "resumable", "dispatch", "action", "transport", "
|
|
|
47
47
|
// #50 C4: socket-shaped identity must not reappear on this surface.
|
|
48
48
|
const FORBIDDEN_C4_KEYS = ["sessions", "socketOnly", "controlDir", "socketPath", "count"];
|
|
49
49
|
|
|
50
|
-
function peer(
|
|
50
|
+
function peer(
|
|
51
|
+
gardenId: string,
|
|
52
|
+
backend: MetaCitizenBackend,
|
|
53
|
+
liveness: FactLiveness,
|
|
54
|
+
observed: Partial<Pick<PeerFact, "receiver" | "transcript">> = {},
|
|
55
|
+
): PeerFact {
|
|
51
56
|
return {
|
|
52
57
|
gardenId,
|
|
53
58
|
backend,
|
|
@@ -57,6 +62,8 @@ function peer(gardenId: string, backend: MetaCitizenBackend, liveness: FactLiven
|
|
|
57
62
|
createdAt: "2026-06-11T00:00:00.000Z",
|
|
58
63
|
recordUpdatedAt: "2026-06-11T00:00:00.000Z",
|
|
59
64
|
liveness,
|
|
65
|
+
receiver: observed.receiver ?? "n/a",
|
|
66
|
+
transcript: observed.transcript ?? "exists",
|
|
60
67
|
};
|
|
61
68
|
}
|
|
62
69
|
|
|
@@ -91,7 +98,7 @@ function main(): void {
|
|
|
91
98
|
peer(GID_PI_ALIVE, "pi", "alive"),
|
|
92
99
|
peer(GID_PI_DEAD, "pi", "dead"),
|
|
93
100
|
peer(GID_PI_INDET, "pi", "indeterminate"),
|
|
94
|
-
peer(GID_CLAUDE, "claude-code", "unsupported"),
|
|
101
|
+
peer(GID_CLAUDE, "claude-code", "unsupported", { receiver: "inactive", transcript: "absent" }),
|
|
95
102
|
],
|
|
96
103
|
// The provider has already folded these into diagnostics; the render layer
|
|
97
104
|
// never re-renders them as a section (facts carry them for the union math).
|
|
@@ -116,6 +123,17 @@ function main(): void {
|
|
|
116
123
|
ok("C4: payload is exactly { peers, diagnostics }", true);
|
|
117
124
|
}
|
|
118
125
|
ok("peers carry all 4 citizens (unsupported NOT dropped)", payload.peers.length === 4);
|
|
126
|
+
// #101: the two observed axes are the only thing separating two same-cwd claude rows,
|
|
127
|
+
// since `liveness` reads `unsupported` for every one of them. They are FACTS on the
|
|
128
|
+
// row — the forbidden-word scans below still apply to them unchanged.
|
|
129
|
+
ok(
|
|
130
|
+
"observed receiver + transcript ride the row in text",
|
|
131
|
+
new RegExp(`${GID_CLAUDE}.*receiver=inactive.*transcript=absent`).test(text),
|
|
132
|
+
);
|
|
133
|
+
ok(
|
|
134
|
+
"observed axes ride the JSON payload too",
|
|
135
|
+
payload.peers.some((p) => p.gardenId === GID_CLAUDE && p.receiver === "inactive" && p.transcript === "absent"),
|
|
136
|
+
);
|
|
119
137
|
ok(
|
|
120
138
|
"unsupported citizen present in peers",
|
|
121
139
|
payload.peers.some((p) => p.gardenId === GID_CLAUDE && p.liveness === "unsupported"),
|
|
@@ -469,8 +469,26 @@ const metaBody = functionBody("buildTrustedMetaSenderEnvelope");
|
|
|
469
469
|
ok("buildTrustedMetaSenderEnvelope calls computeSelfAddressability", /computeSelfAddressability\s*\(/.test(metaBody));
|
|
470
470
|
ok("buildTrustedMetaSenderEnvelope no longer hardcodes `replyable: true`", !/replyable:\s*true/.test(metaBody));
|
|
471
471
|
ok(
|
|
472
|
-
|
|
473
|
-
|
|
472
|
+
// #101: the identity match alone is no longer the whole answer. `entwurf_self` composes
|
|
473
|
+
// the SAME `resolveMailboxReceiverFacts` the v2 dispatch seam uses — reading the receiver
|
|
474
|
+
// marker AND, where the watch owner is the sender-marker process, the join that says that
|
|
475
|
+
// owner is still serving this garden. A citizen's self-reported replyability and what
|
|
476
|
+
// dispatch decides about it come from one measurement, so they cannot disagree.
|
|
477
|
+
"buildTrustedMetaSenderEnvelope derives active-receiver from the SHARED receiver composition (both markers)",
|
|
478
|
+
/resolveMailboxReceiverFacts\s*\(/.test(metaBody) &&
|
|
479
|
+
/readMetaReceiverMarker\s*\(/.test(metaBody) &&
|
|
480
|
+
/readMetaSenderMarker\s*\(/.test(metaBody),
|
|
481
|
+
);
|
|
482
|
+
ok(
|
|
483
|
+
"buildTrustedMetaSenderEnvelope no longer copies one match into both receiver facts",
|
|
484
|
+
!/ownerAlive:\s*active/.test(metaBody) && !/watchArmed:\s*active/.test(metaBody),
|
|
485
|
+
);
|
|
486
|
+
ok(
|
|
487
|
+
// #101 (cross-review): both markers are read with their liveness guards ON. A reader that
|
|
488
|
+
// opted out would accept a dead session's leftover file as "which garden this pid serves
|
|
489
|
+
// now", and every fixture pid in a gate is live, so nothing dynamic here could tell.
|
|
490
|
+
"buildTrustedMetaSenderEnvelope reads neither marker with the owner guard disabled",
|
|
491
|
+
!/verifyOwner:\s*false/.test(metaBody),
|
|
474
492
|
);
|
|
475
493
|
ok(
|
|
476
494
|
"buildTrustedMetaSenderEnvelope keeps meta identity + derived replyable (inactive → not null)",
|
|
@@ -207,11 +207,13 @@ async function main(): Promise<void> {
|
|
|
207
207
|
ok("target-locked: diagnostic carried", d.kind === "reject" && d.diagnostic?.kind === "target-locked");
|
|
208
208
|
ok(
|
|
209
209
|
"target-locked: diagnostic holder pid preserved",
|
|
210
|
-
d.kind === "reject" && d.diagnostic?.conflict.holder?.pid === 4242,
|
|
210
|
+
d.kind === "reject" && d.diagnostic?.kind === "target-locked" && d.diagnostic.conflict.holder?.pid === 4242,
|
|
211
211
|
);
|
|
212
212
|
ok(
|
|
213
213
|
"target-locked: diagnostic lockPath preserved",
|
|
214
|
-
d.kind === "reject" &&
|
|
214
|
+
d.kind === "reject" &&
|
|
215
|
+
d.diagnostic?.kind === "target-locked" &&
|
|
216
|
+
d.diagnostic.conflict.lockPath === `/fake/locks/${GID}.lock`,
|
|
215
217
|
);
|
|
216
218
|
}
|
|
217
219
|
|
|
@@ -253,7 +255,8 @@ async function main(): Promise<void> {
|
|
|
253
255
|
ok(
|
|
254
256
|
"target-locked(corrupt): diagnostic detail preserved",
|
|
255
257
|
d.kind === "reject" &&
|
|
256
|
-
d.diagnostic?.
|
|
258
|
+
d.diagnostic?.kind === "target-locked" &&
|
|
259
|
+
d.diagnostic.conflict.detail === "lockfile is empty, corrupt, or holds a different garden id",
|
|
257
260
|
);
|
|
258
261
|
}
|
|
259
262
|
|
|
@@ -158,6 +158,32 @@ function recordingEnqueue(): {
|
|
|
158
158
|
ok("6: successful enqueue → {success:true}", res.success === true && res.error === undefined);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// ── 6b. #98 R: the enqueue result's messagePath is the SEND receipt ──────────
|
|
162
|
+
// Reported VERBATIM from the enqueue result, never re-derived from the plan — a
|
|
163
|
+
// re-derived path could disagree with the file that was actually written.
|
|
164
|
+
{
|
|
165
|
+
const enq = recordingEnqueue();
|
|
166
|
+
const res = executeMetaMailboxSend(mailboxPlan(), SENDER, { enqueue: enq.fn });
|
|
167
|
+
ok("6b: result carries the enqueue's messagePath verbatim", res.messagePath === `/fake/mailbox/${GID}/m.msg`);
|
|
168
|
+
}
|
|
169
|
+
{
|
|
170
|
+
// The path is taken from the RESULT, not from `plan.mailboxDir` + gardenId: an enqueue
|
|
171
|
+
// that lands somewhere else (env override, tilde expansion, a resolved symlink) must be
|
|
172
|
+
// reported where it actually landed.
|
|
173
|
+
const res = executeMetaMailboxSend(mailboxPlan(), SENDER, {
|
|
174
|
+
enqueue: (opts) => ({
|
|
175
|
+
gardenId: opts.gardenId,
|
|
176
|
+
recordPath: "/elsewhere/rec.json",
|
|
177
|
+
messagePath: "/elsewhere/queued-here.msg",
|
|
178
|
+
signalPath: "/elsewhere/inbox.signal",
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
ok(
|
|
182
|
+
"6b: a messagePath outside the plan's mailboxDir is still reported as-is",
|
|
183
|
+
res.messagePath === "/elsewhere/queued-here.msg",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
161
187
|
// ── 7. production adapter: ignores lock entirely (poison LockClaim) ───────────
|
|
162
188
|
{
|
|
163
189
|
const enq = recordingEnqueue();
|
|
@@ -223,6 +249,13 @@ function recordingEnqueue(): {
|
|
|
223
249
|
for (const forbidden of ["releaseLock", "inspectSocket", "probeSocket", "resolveDispatch", "resolveTarget"]) {
|
|
224
250
|
ok(`9: lib code has no '${forbidden}' (no release / no routing seam)`, !code.includes(forbidden));
|
|
225
251
|
}
|
|
252
|
+
// #98 R, the negative half stated as an assertion: a SEND receipt may never carry a
|
|
253
|
+
// read/delivery stamp. At enqueue time `lastReadAt` holds the PREVIOUS message's read,
|
|
254
|
+
// so carrying it here would let a sender read "my message was read" off a stamp that
|
|
255
|
+
// says nothing of the kind. The per-message read receipt is the `.read` suffix.
|
|
256
|
+
for (const forbidden of ["lastReadAt", "lastDeliveredAt", "readAt"]) {
|
|
257
|
+
ok(`9: lib code never carries '${forbidden}' into the send receipt`, !code.includes(forbidden));
|
|
258
|
+
}
|
|
226
259
|
}
|
|
227
260
|
|
|
228
261
|
console.log(`\ncheck-entwurf-v2-mailbox: ${passed} checks passed`);
|