@junghanacs/entwurf 0.17.2 → 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 +127 -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-production.js +25 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +6 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +38 -0
- 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-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-production.ts +32 -12
- package/pi-extensions/lib/entwurf-v2-surface.ts +6 -0
- package/pi-extensions/lib/meta-session.ts +44 -0
- 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-matrix.ts +10 -5
- package/scripts/check-entwurf-v2-production.ts +72 -1
- package/scripts/check-gate-qualification.ts +1 -0
- package/scripts/check-meta-hook-session-switch.ts +560 -0
- package/scripts/meta-bridge-doctor.sh +29 -4
- package/scripts/mutants/meta-hook-session-switch.json +223 -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-mux-fresh-call-live.ts +13 -3
package/AGENTS.md
CHANGED
|
@@ -90,7 +90,7 @@ A `--entwurf-control` pi session is a citizen for the same reason a native bridg
|
|
|
90
90
|
|
|
91
91
|
- **control-socket domain (currently `pi`)**: socket liveness, per-target lock, live send, and a dormant cell that rejects.
|
|
92
92
|
- **No relaunch transport exists inside delivery.** `spawn-bg` — a detached, window-less resume child — was removed under the visible-first rule, not deprecated behind a reject, and `entwurf_v2` still starts no process: a dormant socket-domain citizen is refused as `dormant-fire-forget-unsupported`. Reopening one is the separate lifecycle verb `entwurf_resume_call`, which is pi-only (`target-not-pi` otherwise), returns a LAUNCH receipt and an OBSERVATION receipt that are never merged, releases its per-gid lock on every path, and on an unobserved socket leaves the visible window open rather than retrying. No watcher, no retry, no supervisor. Do not re-route it through `entwurf_v2` and do not describe a spawn domain that does not exist.
|
|
93
|
-
- **self-fetch domain (Claude Code, Copilot CLI)**: active receiver + mailbox deliverability; no resume authority. The two arm that receiver through different vendor surfaces — a Claude hook that emits watchPaths, a Copilot first-party extension that holds the watch in a forked child — and the marker records which, because the pid a reader verifies differs.
|
|
93
|
+
- **self-fetch domain (Claude Code, Copilot CLI)**: active receiver + mailbox deliverability; no resume authority. The two arm that receiver through different vendor surfaces — a Claude hook that emits watchPaths, a Copilot first-party extension that holds the watch in a forked child — and the marker records which, because the pid a reader verifies differs. A live marker is not by itself an armed doorbell: where the watch owner IS the process the sender marker is keyed to (`ownerKind: claude-code-cli`), that owner may switch sessions in place, so deliverability also requires its sender marker to still name the same garden. That join is `ownerKind`-scoped by construction — a Copilot watch lives in a forked child with its own pid, so the join does not exist there and must never be applied to it (#101).
|
|
94
94
|
- **native-push domain (currently Antigravity)**: adapter probe + direct injection; no mailbox and no resume authority.
|
|
95
95
|
- `origin: "pi-session" | "meta-session" | "external-mcp"` records sender provenance. It is not the citizen identity schema and not a hierarchy.
|
|
96
96
|
- `entwurf_peers` reports record citizens and liveness facts only. It never embeds routing verbs or socket addresses for peers.
|
|
@@ -172,6 +172,7 @@ implement → affected focused gates → independent review → one amendment bu
|
|
|
172
172
|
| `pi-extensions/lib/entwurf-v2-*.ts` | v2 contract, decider, transports, runner, production wiring; visible resume keeps launch injected |
|
|
173
173
|
| `pi-extensions/lib/mux-*.ts` | same-tmux placement plus narrow fresh-call and visible-resume launch compositions |
|
|
174
174
|
| `pi-extensions/lib/entwurf-fact*.ts` | record citizens + transport-specific liveness facts |
|
|
175
|
+
| `pi-extensions/lib/entwurf-peer-observe.ts` | the IO half of the observed peer facts (receiver / transcript) |
|
|
175
176
|
| `pi-extensions/lib/native-push/` | native-push adapter/probe/register leaf |
|
|
176
177
|
| `pi-extensions/acp-provider.ts` | `entwurf` provider registration |
|
|
177
178
|
| `pi-extensions/lib/acp/` | ACP adapter rail, config/overlay, augment, turn loop, event mapping |
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,133 @@ All notable changes to this project will be documented here. Format follows [Kee
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.18.0 - 2026-09-04
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **A Claude window that changes which session it serves no longer leaves an armed receiver behind
|
|
12
|
+
(#101).** One process serves one garden at a time but can change which: open a bare `claude` and
|
|
13
|
+
then `/resume` or `/clear` inside it, and a second `SessionStart` arrives under the same pid,
|
|
14
|
+
seconds later, naming a different session. The garden it stopped serving kept a receiver marker
|
|
15
|
+
naming a LIVE owner, so dispatch read an armed doorbell nobody held — a sibling's message was
|
|
16
|
+
reported as sent and then sat unread in that mailbox for over 50 minutes (oracle, 2026-09-04; that
|
|
17
|
+
unread letter is what opened the issue).
|
|
18
|
+
- **Deliverability now asks about *now*, not *ever*.** `watchArmed` was a copy of the identity
|
|
19
|
+
match; it is a measurement — the receiver owner's own sender marker
|
|
20
|
+
(`meta-senders/<backend>/<pid>.json`) must still name the same garden. Both consumers, the v2
|
|
21
|
+
production seam and `entwurf_self`, go through one shared composition, so a citizen's
|
|
22
|
+
self-report and dispatch cannot disagree.
|
|
23
|
+
- **The hook retires what it stopped serving** — the marker only, never the record (a record is a
|
|
24
|
+
citizen's identity, not sweepable state), only a marker its own pid owns, and only on an
|
|
25
|
+
arm-capable event. `UserPromptSubmit` cannot emit `watchPaths`, so retiring on a keystroke would
|
|
26
|
+
disarm the session the operator is sitting in with no way to re-arm. The vendor's `source` is
|
|
27
|
+
logged on every line and branched on nowhere: the switch is settled by what is on disk.
|
|
28
|
+
- **The join is scoped by the marker's `ownerKind`, not by backend** — today that is
|
|
29
|
+
`claude-code-cli`. A Copilot watch lives in a forked extension child with its own pid while its
|
|
30
|
+
sender marker carries the CLI's `process.ppid`, so applying the same check there would make
|
|
31
|
+
every Copilot citizen permanently `mailbox-undeliverable`. That is a scope decision with a
|
|
32
|
+
measured reason, not an omission.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **A rejected delivery now names which receiver axis failed (#101).** A bare `mailbox-undeliverable`
|
|
37
|
+
sent one sibling hunting for a live session it had read as dead. The rejection now says whether
|
|
38
|
+
the doorbell is not armed, the owner is not alive, or there is no record.
|
|
39
|
+
- **`entwurf_peers` separates a live citizen from a phantom (#101).** Every claude-code row reads
|
|
40
|
+
`liveness=unsupported`, so two rows were byte-identical whether or not anyone was home. Each row
|
|
41
|
+
now carries two observed facts — `receiver=` (doorbell state) and `transcript=` (whether a
|
|
42
|
+
conversation was ever written):
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
- 20260904T072015-e09b66 liveness=unsupported receiver=active transcript=exists
|
|
46
|
+
- 20260904T093135-ac7a1a liveness=unsupported receiver=inactive transcript=absent
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- **`check-meta-hook-session-switch` (28 assertions) and the first mutant lane the Claude hook has
|
|
50
|
+
ever had (#101).** The gate drives the shipped launcher twice under one fake owner pid and
|
|
51
|
+
requires that exactly one of the two gardens is deliverable — and that it is the one the operator
|
|
52
|
+
is sitting in. `scripts/mutants/meta-hook-session-switch.json` carries **17 claims**, 1:1 with the
|
|
53
|
+
gate's `[QK:…]` labels, each re-planting the defect this lane closed and requiring the gate to go
|
|
54
|
+
red at its claimed signature. Three of those six new mutants exist because cross-review found two
|
|
55
|
+
QK labels with no mutant behind them and three real weakenings walking straight through: the
|
|
56
|
+
join's `ownerKind` scope, the sender marker's start-key guard, and its backend equality.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- **One inherited sentence about the resume picker is retired, and it was retired by measurement
|
|
61
|
+
(#101).** The diagnosis said the picker fires `SessionStart` twice — a placeholder id, then the
|
|
62
|
+
picked one. Six LIVE cells on this host (Claude Code 2.1.260, hook log verbatim in
|
|
63
|
+
`scripts/raw-claude-session-switch/README.md`) show it fires **once**, carrying the real id,
|
|
64
|
+
whether the id comes from the picker or from argv. The two-`SessionStart` shape is a bare `claude`
|
|
65
|
+
followed by an in-session `/resume` or `/clear` — which is exactly what the field case did, four
|
|
66
|
+
seconds before its second envelope. The repair is unaffected (either way one pid stops serving one
|
|
67
|
+
garden), so only the prose moved, in four places. Compaction, manual and automatic, re-fires
|
|
68
|
+
`SessionStart` for the **same** native id and retires nothing; the same-garden rule already
|
|
69
|
+
covered it.
|
|
70
|
+
- **A `UserPromptSubmit` envelope is trusted for the receiver join, on a measured footing.** The
|
|
71
|
+
cross-review threat model ("a stale or out-of-order UPS") was withdrawn rather than defended: it
|
|
72
|
+
claimed the sender pointer is untrustworthy for the join while the same pointer stays authoritative
|
|
73
|
+
for sender identity — two incompatible readings of one file. Across the raw lab's four pids, every
|
|
74
|
+
`UserPromptSubmit` named the native id its own pid's preceding `SessionStart` had established,
|
|
75
|
+
**8 of 8**. The arm-capable restriction stays on its own footing.
|
|
76
|
+
- **Three LIVE smokes stopped depending on a fixture the new join reads as retired.** Five smokes
|
|
77
|
+
seeded a receiver marker with no sender marker beside it — three of them release MUSTs — so each
|
|
78
|
+
would have failed on its own fixture rather than on the rail it exists to prove. They now seed
|
|
79
|
+
both and sandbox the senders root, the shape `smoke-mux-lifecycle-live` and `smoke-omp-fresh-live`
|
|
80
|
+
already had. `smoke-entwurf-chain-live` additionally gives its terminus an owner **outside hop 1's
|
|
81
|
+
ancestry**: seeding it under the smoke's own pid put two garden citizens on one host process and
|
|
82
|
+
the bridge refused the hop outright with `ambiguous sender identity`.
|
|
83
|
+
|
|
84
|
+
### Upgrade note
|
|
85
|
+
|
|
86
|
+
**Run `entwurf setup` once after upgrading — every rail, not just Claude.** This release changes
|
|
87
|
+
`pi-extensions/lib/meta-session.ts` and the Claude hook, and **four install paths deploy that
|
|
88
|
+
file** — `install-meta-bridge` (Claude), `install-omp-bridge` and `install-omp-receive` (OMP),
|
|
89
|
+
`install-copilot-bridge` (Copilot). Every one of them present on the host now carries a STALE
|
|
90
|
+
writer until it is re-installed, and its own doctor says so by name. Re-installing only the Claude
|
|
91
|
+
rail leaves the others stale, and that does not surface until a LIVE gate turns red — measured in
|
|
92
|
+
the 0.17.2 cut, where it blocked `smoke-omp-receive-live` and made the first `--cut` run BLOCKED.
|
|
93
|
+
|
|
94
|
+
`entwurf setup` is presence-driven and re-synthesizes exactly the units this host has, which is why
|
|
95
|
+
it is the upgrade command rather than any single `install-*`. An already-open Claude Code session
|
|
96
|
+
keeps the old manifest until it restarts — and, for this release specifically, an already-open
|
|
97
|
+
session also keeps the old hook, which is the code that retires a switched-away receiver.
|
|
98
|
+
|
|
99
|
+
### Verification
|
|
100
|
+
|
|
101
|
+
All of the following ran on oracle (Linux, Claude Code 2.1.260, node 24.18.1, pi 0.84.4, omp 18.0.0).
|
|
102
|
+
|
|
103
|
+
- **`pnpm run check:full` — exit 0**, 440s on the prepared tree and 446s inside the release gate.
|
|
104
|
+
- **`LIVE=1 ./run.sh release-gate /tmp/entwurf-release-gate-0.18.0c.xZlKjZ --cut` — `cut: OK`,
|
|
105
|
+
exit 0.** **MUST PASS=23 FAIL=0 SKIP=0**, **BEHAVIOR PASS=1 FAIL=0 SKIP=0**. Run 18:57→19:48 KST
|
|
106
|
+
on `faee8f6`, with `env -u CLAUDE_CONFIG_DIR -u PI_SESSION_ID -u PI_AGENT_ID`. Log:
|
|
107
|
+
`/tmp/entwurf-release-gate-0.18.0c.xZlKjZ/release-gate.log`. It carried `check:full` and
|
|
108
|
+
`check-gate-qualification` (**364/364 KILLED**, up from 347 — the 17 new claims are this lane's
|
|
109
|
+
`MHSS-*`) as MUST steps.
|
|
110
|
+
- **This lane's own gate: `check-meta-hook-session-switch` — 28 assertions passed**, and all 17
|
|
111
|
+
`MHSS-*` mutants killed at their claimed signatures inside that qualification run.
|
|
112
|
+
- **The three LIVE smokes whose fixtures this lane changed are green**, including
|
|
113
|
+
`smoke-entwurf-chain-live` — **24 assertions**, four citizens across three harnesses, and its new
|
|
114
|
+
pre-flight cell `fixture: the terminus is a deliverable citizen at the moment the chain starts`
|
|
115
|
+
passed before the chain ran.
|
|
116
|
+
- **One unattributed `smoke-entwurf-chain-live` failure is on the record and is NOT closed.** During
|
|
117
|
+
the lane (2026-09-04 16:58 KST) one run rejected at hop 3 with `mailbox-undeliverable (observed
|
|
118
|
+
liveness: unsupported)`; the runs before and after it passed and it has not reproduced. A
|
|
119
|
+
start-key race and an early idle-owner exit were excluded by measurement; a memory-pressure
|
|
120
|
+
hypothesis is neither confirmed nor refuted (no OOM or kill entries in the host journal for that
|
|
121
|
+
window). The instrument that will split the next occurrence shipped in this release — the fixture
|
|
122
|
+
pre-flight assertion above, plus a `terminus fixture at timeout:` line naming owner liveness and
|
|
123
|
+
both receiver facts.
|
|
124
|
+
- **The first `--cut` attempt of this cut was BLOCKED, and the cause was operator error, not the
|
|
125
|
+
product.** `check-gate-qualification` aborted with `origin HEAD changed during qualification`
|
|
126
|
+
because a commit was created while the gate was running. Every other MUST step in that run passed
|
|
127
|
+
(`MUST PASS=22 FAIL=1 SKIP=0`). The run above is the re-measurement on a frozen HEAD. One LIVE
|
|
128
|
+
gate run before that was killed by the host harness's low-memory watchdog rather than by any
|
|
129
|
+
assertion, and was re-run under tmux.
|
|
130
|
+
- **Exact-SHA CI on the pre-version HEAD `e56eee0`** — `check`, `install-surface`,
|
|
131
|
+
`artifact-consumer` all `success`.
|
|
132
|
+
Run: https://github.com/junghan0611/entwurf/actions/runs/33853363923
|
|
133
|
+
|
|
7
134
|
## 0.17.2 - 2026-09-03
|
|
8
135
|
|
|
9
136
|
### Added
|
|
@@ -64,7 +64,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
64
64
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
65
65
|
import { z } from "zod";
|
|
66
66
|
import { controlSocketPathIn, defaultControlSocketDir } from "../../../pi-extensions/lib/control-socket-path.js";
|
|
67
|
-
import {
|
|
67
|
+
import { resolveMailboxReceiverFacts } from "../../../pi-extensions/lib/entwurf-deliverability.js";
|
|
68
68
|
import { listEntwurfFacts } from "../../../pi-extensions/lib/entwurf-fact-provider.js";
|
|
69
69
|
import { renderEntwurfPeers } from "../../../pi-extensions/lib/entwurf-peers-render.js";
|
|
70
70
|
import { computeSelfAddressability } from "../../../pi-extensions/lib/entwurf-self-address.js";
|
|
@@ -73,7 +73,7 @@ import { resolveMailboxWakeModeCapability } from "../../../pi-extensions/lib/ent
|
|
|
73
73
|
import { runAndRenderEntwurfV2FromSurface } from "../../../pi-extensions/lib/entwurf-v2-surface.js";
|
|
74
74
|
import { makeVisibleResumeDeps, renderVisibleResume, visibleResume, } from "../../../pi-extensions/lib/entwurf-v2-visible-resume.js";
|
|
75
75
|
import { probeNativeSenderAlive, resolveTrustedMetaSenderIdentity, } from "../../../pi-extensions/lib/meta-sender-identity.js";
|
|
76
|
-
import { applyOmpBridgeChildRootPolicy, defaultMetaMailboxDir, defaultMetaSessionsDir, makeStoreRecordReader, readActiveStoreEntries, readMetaInbox, readMetaReceiverMarker, } from "../../../pi-extensions/lib/meta-session.js";
|
|
76
|
+
import { applyOmpBridgeChildRootPolicy, defaultMetaMailboxDir, defaultMetaSessionsDir, makeStoreRecordReader, readActiveStoreEntries, readMetaInbox, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "../../../pi-extensions/lib/meta-session.js";
|
|
77
77
|
import { freshCall, renderFreshCall } from "../../../pi-extensions/lib/mux-fresh-call.js";
|
|
78
78
|
import { RESUME_CALL_REJECT_HINT, resumeCall } from "../../../pi-extensions/lib/mux-resume-call.js";
|
|
79
79
|
import { registerNativeConversation } from "../../../pi-extensions/lib/native-push/register.js";
|
|
@@ -223,9 +223,12 @@ async function buildTrustedMetaSenderEnvelope(cwd = process.cwd()) {
|
|
|
223
223
|
// admits another self-fetch citizen.
|
|
224
224
|
// none ← neither. omp today: no mailbox drain, no native-push adapter. Rendering
|
|
225
225
|
// this as self-fetch printed a mailboxPath nothing drains.
|
|
226
|
-
// self-fetch (claude-code/copilot): can this citizen's own inbox wake? → the
|
|
227
|
-
//
|
|
228
|
-
//
|
|
226
|
+
// self-fetch (claude-code/copilot): can this citizen's own inbox wake? → the SHARED
|
|
227
|
+
// receiver composition `resolveMailboxReceiverFacts`, the same one the v2 dispatch seam
|
|
228
|
+
// uses, so a citizen's self-reported replyability can never disagree with what dispatch
|
|
229
|
+
// decides about it. It reads the presence marker (a dead/reused owner already folds to
|
|
230
|
+
// null) AND, where the watch owner is the sender-marker process, the #101 join that says
|
|
231
|
+
// the owner is still serving THIS garden rather than one it switched away from.
|
|
229
232
|
// native-push (antigravity): there is no inbox and no watch. A reply is injected into a
|
|
230
233
|
// live app-server conversation, so only an adapter probe can answer. Composing the
|
|
231
234
|
// receiver atom here would demand `watchArmed` from a backend that never arms one, and
|
|
@@ -248,14 +251,16 @@ async function buildTrustedMetaSenderEnvelope(cwd = process.cwd()) {
|
|
|
248
251
|
}
|
|
249
252
|
: metaDeliveryDomain === "self-fetch"
|
|
250
253
|
? (() => {
|
|
251
|
-
const receiver =
|
|
252
|
-
|
|
254
|
+
const receiver = resolveMailboxReceiverFacts(identity, {
|
|
255
|
+
readReceiverMarker: (gardenId) => readMetaReceiverMarker({ gardenId }),
|
|
256
|
+
readSenderMarker: (backend, ownerPid) => readMetaSenderMarker({ backend: requireBackend(backend), ownerPid }),
|
|
257
|
+
});
|
|
253
258
|
return {
|
|
254
259
|
origin: "meta-session",
|
|
255
260
|
metaDeliveryDomain,
|
|
256
261
|
recordBacked: true,
|
|
257
|
-
ownerAlive:
|
|
258
|
-
watchArmed:
|
|
262
|
+
ownerAlive: receiver.ownerAlive,
|
|
263
|
+
watchArmed: receiver.watchArmed,
|
|
259
264
|
};
|
|
260
265
|
})()
|
|
261
266
|
: {
|
|
@@ -72,6 +72,82 @@ export function receiverMarkerMatchesIdentity(marker, identity) {
|
|
|
72
72
|
marker.backend === identity.backend &&
|
|
73
73
|
marker.nativeSessionId === identity.nativeSessionId);
|
|
74
74
|
}
|
|
75
|
+
// ── the watch-owner ↔ sender-marker join (#101 결함 B) ──────────────────────
|
|
76
|
+
// A receiver marker proves "a LIVE process once armed a watch for this garden".
|
|
77
|
+
// It does NOT prove "that process is serving this garden RIGHT NOW". One native
|
|
78
|
+
// process can hold markers for several gardens and drain exactly one of them:
|
|
79
|
+
// Claude Code's session switch (an in-session `/resume` or `/clear`) fires a second
|
|
80
|
+
// SessionStart inside the SAME pid under a NEW native session id, so the first
|
|
81
|
+
// garden's marker keeps naming a live owner forever while the doorbell it advertises
|
|
82
|
+
// is gone. Measured on oracle 2026-09-04 (#101): one pid held both markers, a
|
|
83
|
+
// 09:33 enqueue to the retired garden sat unread as a bare `.msg` for ≥50 minutes —
|
|
84
|
+
// the "enqueue into a void" this module's header exists to refuse.
|
|
85
|
+
//
|
|
86
|
+
// The join that decides it is already on disk: the SENDER marker is keyed by owner
|
|
87
|
+
// pid and rewritten with the CURRENT garden on every arm/prompt, so
|
|
88
|
+
// `meta-senders/<backend>/<ownerPid>.json.gardenId` is the answer to "which garden
|
|
89
|
+
// does this process serve now?". A receiver marker naming a different garden than
|
|
90
|
+
// its own owner's sender marker is a retired watch.
|
|
91
|
+
//
|
|
92
|
+
// SCOPE IS NOT UNIVERSAL, AND THE MARKER SAYS SO. The join only exists where the
|
|
93
|
+
// watch owner IS the process the sender marker is keyed to. That is true for the
|
|
94
|
+
// Claude hook (one `ownerPid` variable writes both markers —
|
|
95
|
+
// `meta-bridge-hook.ts` sender + receiver arm) and false BY CONSTRUCTION for
|
|
96
|
+
// Copilot, whose watch lives in a forked first-party extension child
|
|
97
|
+
// (`extension.mjs` writes `ownerPid: process.pid`) while its sender marker is keyed
|
|
98
|
+
// to the CLI parent (`meta-bridge-hook-copilot.ts` uses `process.ppid`). Applying
|
|
99
|
+
// the join there would make every Copilot citizen permanently undeliverable — a
|
|
100
|
+
// regression on a shipped lane, not a fix. `ownerKind` is recorded on the marker for
|
|
101
|
+
// exactly this reason ("the marker records which, because the pid a reader verifies
|
|
102
|
+
// differs" — AGENTS.md, self-fetch domain), so it is the axis, not `backend`.
|
|
103
|
+
//
|
|
104
|
+
// `omp-host` is a CANDIDATE, deliberately not admitted here: OMP already retires the
|
|
105
|
+
// previous garden in-process on its `/new` edge (the unarm the claude hook lacked),
|
|
106
|
+
// so the cell this join closes has a different owner there. Admitting it needs its
|
|
107
|
+
// own measurement, not this file's optimism.
|
|
108
|
+
export const SENDER_JOINED_RECEIVER_OWNER_KINDS = ["claude-code-cli"];
|
|
109
|
+
/** Does this watch owner share its pid with the backend's sender marker? */
|
|
110
|
+
export function receiverOwnerKindJoinsSender(ownerKind) {
|
|
111
|
+
return SENDER_JOINED_RECEIVER_OWNER_KINDS.includes(ownerKind);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Is the watch owner still serving THIS garden? Fail-closed inside the join's scope:
|
|
115
|
+
* an absent/unreadable sender marker, or one naming another garden or backend, means
|
|
116
|
+
* the watch is retired. Outside the scope (an ownerKind whose watch owner is not the
|
|
117
|
+
* sender-marker process) the join does not apply and the marker's own live-owner guard
|
|
118
|
+
* is the whole rule — returning true here is NOT optimism, it is "this axis says
|
|
119
|
+
* nothing", and the caller has already required the marker to match the identity.
|
|
120
|
+
*/
|
|
121
|
+
export function receiverOwnerServesGarden(marker, senderMarker) {
|
|
122
|
+
if (!receiverOwnerKindJoinsSender(marker.ownerKind))
|
|
123
|
+
return true;
|
|
124
|
+
return !!senderMarker && senderMarker.gardenId === marker.gardenId && senderMarker.backend === marker.backend;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* THE single composition of the two mailbox receiver facts, over injected readers.
|
|
128
|
+
* Both production consumers — the v2 `mailboxDeliverabilityFor` seam and the MCP
|
|
129
|
+
* bridge's `entwurf_self` — call THIS, so a direct send, a re-resolved fallback send
|
|
130
|
+
* and a citizen's own replyability can never drift to different verdicts.
|
|
131
|
+
*
|
|
132
|
+
* ownerAlive — a live-owner marker that names THIS identity (the reader already ran
|
|
133
|
+
* the plausibility + start-key guards; this adds the identity match).
|
|
134
|
+
* watchArmed — that owner is still serving this garden (the join above). It is a
|
|
135
|
+
* MEASUREMENT, never a copy of ownerAlive: copying it is what let a
|
|
136
|
+
* retired watch read as an armed doorbell (#101 결함 B).
|
|
137
|
+
*
|
|
138
|
+
* `recordBacked` is NOT decided here — it stays the caller's explicit fact, so an
|
|
139
|
+
* absent record and a dead owner stay distinguishable in the reason string.
|
|
140
|
+
*/
|
|
141
|
+
export function resolveMailboxReceiverFacts(identity, readers) {
|
|
142
|
+
const marker = readers.readReceiverMarker(identity.gardenId);
|
|
143
|
+
const ownerAlive = receiverMarkerMatchesIdentity(marker, identity);
|
|
144
|
+
if (!ownerAlive || !marker)
|
|
145
|
+
return { ownerAlive: false, watchArmed: false };
|
|
146
|
+
const senderMarker = receiverOwnerKindJoinsSender(marker.ownerKind)
|
|
147
|
+
? readers.readSenderMarker(marker.backend, marker.ownerPid)
|
|
148
|
+
: null;
|
|
149
|
+
return { ownerAlive, watchArmed: receiverOwnerServesGarden(marker, senderMarker) };
|
|
150
|
+
}
|
|
75
151
|
/**
|
|
76
152
|
* The conversational-mailbox enqueue gate. False (no enqueue) unless the backend is
|
|
77
153
|
* self-fetch AND the receiver is active. A direct-inject backend (pi / codex /
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
* a same-gid socket exists would be half a lie. Both leave the normal output;
|
|
27
27
|
* one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
|
|
28
28
|
*/
|
|
29
|
-
import { isOutOfSocketDomainGardenIdConflict, resolveFactList } from "./entwurf-facts.js";
|
|
29
|
+
import { isOutOfSocketDomainGardenIdConflict, resolveFactList, } from "./entwurf-facts.js";
|
|
30
|
+
import { observePeerFacts } from "./entwurf-peer-observe.js";
|
|
30
31
|
import { isLivenessSupported } from "./entwurf-v2-contract.js";
|
|
31
32
|
import { FRESH_CUT_PRESCRIPTION, listAllMetaIdentities, } from "./meta-session.js";
|
|
32
33
|
import { scanSocketProbes } from "./socket-discovery.js";
|
|
@@ -65,10 +66,12 @@ function diagnosticSortKey(d) {
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
/**
|
|
68
|
-
* Assemble the facts-only listing.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
69
|
+
* Assemble the facts-only listing. Driven entirely through injected deps, so the gate
|
|
70
|
+
* runs it without a filesystem; slice 4c supplies the real readdir / readFile / probe.
|
|
71
|
+
* The one dep with a REAL default is `observe` (#101) — see its comment: a per-citizen
|
|
72
|
+
* measurement both wiring sites must make identically is safer as one default than as two
|
|
73
|
+
* call-site arguments. Probes are gid + liveness only (#50 C4 — the per-socket get_info
|
|
74
|
+
* enrich left with the socket-only quasi-citizen listing).
|
|
72
75
|
*/
|
|
73
76
|
export async function listEntwurfFacts(deps) {
|
|
74
77
|
const diagnostics = [];
|
|
@@ -129,7 +132,7 @@ export async function listEntwurfFacts(deps) {
|
|
|
129
132
|
// fire as the last line of defense, never caught here.
|
|
130
133
|
const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
|
|
131
134
|
const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
|
|
132
|
-
const facts = resolveFactList(cleanIdentities, cleanProbes);
|
|
135
|
+
const facts = resolveFactList(cleanIdentities, cleanProbes, deps.observe ?? observePeerFacts);
|
|
133
136
|
// 5. #50 C4 demotion: a record-less socket is a diagnostic, not a listing
|
|
134
137
|
// section. One diagnostic per socket (subjects aggregate at render, F8);
|
|
135
138
|
// the message is liveness-keyed so same-state sockets group into one line.
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* a peer-facing fact; `entwurf_peers` exposes identity + cwd-history, not
|
|
20
20
|
* filesystem internals. (who-can / dispatch read it via the meta-record
|
|
21
21
|
* directly when they genuinely need it — it does not belong in the listing.)
|
|
22
|
+
* Whether that transcript EXISTS is a different thing and is carried (#101):
|
|
23
|
+
* the path stays private, the fact that a citizen has never written a turn
|
|
24
|
+
* does not.
|
|
22
25
|
*
|
|
23
26
|
* The 4-value liveness (`alive|dead|indeterminate|unsupported`, R3b) and the
|
|
24
27
|
* out-of-domain → `unsupported` rule (R1: never coerce an unprobed backend to
|
|
@@ -27,6 +30,9 @@
|
|
|
27
30
|
* the surrounding identity facts around it.
|
|
28
31
|
*/
|
|
29
32
|
import { factLivenessOf, isLivenessSupported } from "./entwurf-v2-contract.js";
|
|
33
|
+
/** What a caller that measured nothing must say. Explicit and greppable — a row that
|
|
34
|
+
* silently defaulted to `none`/`absent` would be a fabricated fact. */
|
|
35
|
+
export const UNOBSERVED_PEER = { receiver: "unobserved", transcript: "unobserved" };
|
|
30
36
|
/**
|
|
31
37
|
* Compose a `PeerFact` from a citizen's identity and an optional socket probe.
|
|
32
38
|
*
|
|
@@ -39,7 +45,7 @@ import { factLivenessOf, isLivenessSupported } from "./entwurf-v2-contract.js";
|
|
|
39
45
|
*
|
|
40
46
|
* Pure: same inputs → same output, no IO.
|
|
41
47
|
*/
|
|
42
|
-
export function resolvePeerFact(identity, socket) {
|
|
48
|
+
export function resolvePeerFact(identity, socket, observations = UNOBSERVED_PEER) {
|
|
43
49
|
return {
|
|
44
50
|
gardenId: identity.gardenId,
|
|
45
51
|
backend: identity.backend,
|
|
@@ -49,6 +55,8 @@ export function resolvePeerFact(identity, socket) {
|
|
|
49
55
|
createdAt: identity.createdAt,
|
|
50
56
|
recordUpdatedAt: identity.recordUpdatedAt,
|
|
51
57
|
liveness: factLivenessOf(identity.backend, socket),
|
|
58
|
+
receiver: observations.receiver,
|
|
59
|
+
transcript: observations.transcript,
|
|
52
60
|
};
|
|
53
61
|
}
|
|
54
62
|
/**
|
|
@@ -98,7 +106,7 @@ export function isOutOfSocketDomainGardenIdConflict(backend, gardenId, socketGid
|
|
|
98
106
|
* A gardenId is never emitted as both a `PeerFact` and a `RecordLessSocketFact`;
|
|
99
107
|
* a record-less socket becomes a `PeerFact` the moment a record claims the gid.
|
|
100
108
|
*/
|
|
101
|
-
export function resolveFactList(identities, socketProbes) {
|
|
109
|
+
export function resolveFactList(identities, socketProbes, observe = () => UNOBSERVED_PEER) {
|
|
102
110
|
const probeMap = new Map();
|
|
103
111
|
for (const probe of socketProbes) {
|
|
104
112
|
if (probeMap.has(probe.gardenId)) {
|
|
@@ -129,7 +137,7 @@ export function resolveFactList(identities, socketProbes) {
|
|
|
129
137
|
}
|
|
130
138
|
socket = null;
|
|
131
139
|
}
|
|
132
|
-
peers.push(resolvePeerFact(identity, socket));
|
|
140
|
+
peers.push(resolvePeerFact(identity, socket, observe(identity)));
|
|
133
141
|
consumed.add(gid);
|
|
134
142
|
}
|
|
135
143
|
const recordLessSockets = [];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-peer-observe — the IO half of the two OBSERVED peer facts (#101).
|
|
3
|
+
*
|
|
4
|
+
* `entwurf_peers` used to answer a claude-code citizen with `liveness=unsupported` and
|
|
5
|
+
* nothing else, because the control-socket probe genuinely does not apply to a self-fetch
|
|
6
|
+
* backend. Two same-cwd rows — one a live conversation, one an abandoned startup session
|
|
7
|
+
* whose transcript was never written — were therefore byte-identical in the listing, and
|
|
8
|
+
* a caller picking "the newest record in this cwd" (the natural heuristic, and the one the
|
|
9
|
+
* surface encourages) picked the phantom as often as the real one. Both halves of the
|
|
10
|
+
* distinction were already on disk; nothing read them.
|
|
11
|
+
*
|
|
12
|
+
* FACTS, NOT VERBS. `receiver` and `transcript` say what was found, never what to do
|
|
13
|
+
* about it: no `sendable`, no `deliverable`, no transport. The listing stays a listing —
|
|
14
|
+
* dispatch still computes routing at call time from the same underlying markers.
|
|
15
|
+
*
|
|
16
|
+
* ONE MEASUREMENT, TWO WORDS. `receiver` is derived from the SAME
|
|
17
|
+
* `resolveMailboxReceiverFacts` composition the v2 dispatch seam and `entwurf_self` use.
|
|
18
|
+
* The surfaces are allowed to phrase it differently — a listing wants an enum, a reject
|
|
19
|
+
* wants a sentence — but they may not disagree, so the enum is a projection of that atom
|
|
20
|
+
* rather than a second opinion about the same markers.
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import { resolveMailboxReceiverFacts } from "./entwurf-deliverability.js";
|
|
24
|
+
import { metaCapabilityFor, metaReceiverMarkerPath, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "./meta-session.js";
|
|
25
|
+
/**
|
|
26
|
+
* The mailbox receiver axis for one citizen.
|
|
27
|
+
*
|
|
28
|
+
* `n/a` is not a failure: a backend with no mailbox (pi's control socket, antigravity's
|
|
29
|
+
* native push) has no receiver marker to be right or wrong about, and printing `none`
|
|
30
|
+
* there would invent a missing thing. The `inactive` / `none` split is what a reader
|
|
31
|
+
* needs to tell "a watch was armed and is no longer valid" from "no watch was ever
|
|
32
|
+
* armed here", so it is taken from the marker FILE's existence rather than from the
|
|
33
|
+
* reader's null — the reader folds absent, corrupt and dead-owner into one null.
|
|
34
|
+
*/
|
|
35
|
+
function observeReceiver(identity) {
|
|
36
|
+
if (metaCapabilityFor(identity.backend).wakeMode !== "self-fetch")
|
|
37
|
+
return "n/a";
|
|
38
|
+
const facts = resolveMailboxReceiverFacts(identity, {
|
|
39
|
+
readReceiverMarker: (gardenId) => readMetaReceiverMarker({ gardenId }),
|
|
40
|
+
readSenderMarker: (backend, ownerPid) => readMetaSenderMarker({ backend: requireBackend(backend), ownerPid }),
|
|
41
|
+
});
|
|
42
|
+
if (facts.ownerAlive && facts.watchArmed)
|
|
43
|
+
return "active";
|
|
44
|
+
return fs.existsSync(metaReceiverMarkerPath(identity.gardenId)) ? "inactive" : "none";
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Does the recorded transcript exist? A record is minted at SessionStart, but a harness
|
|
48
|
+
* that writes its transcript lazily (Claude Code writes on the first turn) leaves a
|
|
49
|
+
* citizen with no conversation behind it until someone actually says something. The PATH
|
|
50
|
+
* stays private — only its existence crosses to the listing.
|
|
51
|
+
*/
|
|
52
|
+
function observeTranscript(identity) {
|
|
53
|
+
try {
|
|
54
|
+
// A record with no recorded transcript path has nothing to stat — that is the same
|
|
55
|
+
// observable state as a path that is not there, and neither is an `exists` claim.
|
|
56
|
+
return identity.transcriptPath && fs.existsSync(identity.transcriptPath) ? "exists" : "absent";
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// A transcript we cannot stat is not a transcript we can claim exists.
|
|
60
|
+
return "absent";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The production observer: both axes, measured for one citizen. */
|
|
64
|
+
export function observePeerFacts(identity) {
|
|
65
|
+
return { receiver: observeReceiver(identity), transcript: observeTranscript(identity) };
|
|
66
|
+
}
|
|
@@ -22,9 +22,18 @@
|
|
|
22
22
|
* socketPath rows — the pre-record socket-scan worldview) is gone with the
|
|
23
23
|
* `controlDir` it exposed. Socket paths are dispatch-internal transport.
|
|
24
24
|
*/
|
|
25
|
+
// `receiver` and `transcript` are OBSERVED facts, not routing (#101): they say what was
|
|
26
|
+
// found on disk for this citizen, and a caller still asks entwurf_v2 what may be done
|
|
27
|
+
// about it. They earn a column because for every claude-code row `liveness` reads
|
|
28
|
+
// `unsupported` — the socket probe does not apply — so two rows in one cwd, a live
|
|
29
|
+
// conversation and an abandoned startup session whose transcript was never written, used to
|
|
30
|
+
// be indistinguishable here. `receiver=active|inactive|none|n/a` is a projection of the
|
|
31
|
+
// same measurement dispatch uses; `transcript=exists|absent` is the record's transcript,
|
|
32
|
+
// existence only, never the path.
|
|
25
33
|
function renderPeerLine(p) {
|
|
26
34
|
const model = p.model ?? "(unknown)";
|
|
27
|
-
return `- ${p.gardenId} backend=${p.backend} liveness=${p.liveness}
|
|
35
|
+
return (`- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} receiver=${p.receiver} ` +
|
|
36
|
+
`transcript=${p.transcript} cwd=${p.cwd} model=${model}`);
|
|
28
37
|
}
|
|
29
38
|
function renderDiagnosticLine(d) {
|
|
30
39
|
switch (d.kind) {
|
|
@@ -151,8 +151,14 @@ export async function decideDispatch(input, deps) {
|
|
|
151
151
|
// fail-closed (SE-2 2d-3). resolveDispatch then routes intent × deliverable.
|
|
152
152
|
const deliverability = await deps.mailboxDeliverabilityFor(identity);
|
|
153
153
|
const receipt = resolveDispatch(input.intent, "unsupported", deliverability.deliverable);
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
// The predicate's reason travels with the reject (#101 갭 C). It is attached only when
|
|
155
|
+
// undeliverability is what produced the reject — an intent-shaped refusal on a
|
|
156
|
+
// DELIVERABLE target must not be dressed up as a receiver problem.
|
|
157
|
+
if (!receipt.ok) {
|
|
158
|
+
return deliverability.deliverable
|
|
159
|
+
? reject(receipt)
|
|
160
|
+
: reject(receipt, { kind: "mailbox-undeliverable", reason: deliverability.reason });
|
|
161
|
+
}
|
|
156
162
|
// the only allow cell here is fire-and-forget → meta-mailbox send.
|
|
157
163
|
const plan = {
|
|
158
164
|
transport: "meta-mailbox",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* claiming "no conflict". The decider's later `inspectSocket` probe is a SEPARATE step.
|
|
30
30
|
*/
|
|
31
31
|
import { sendRpcCommand as realSendRpc, } from "./entwurf-control-rpc.js";
|
|
32
|
-
import { mailboxConversationalDeliverable,
|
|
32
|
+
import { mailboxConversationalDeliverable, resolveMailboxReceiverFacts, } from "./entwurf-deliverability.js";
|
|
33
33
|
import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.js";
|
|
34
34
|
import { isLivenessSupported } from "./entwurf-v2-contract.js";
|
|
35
35
|
import { decideDispatch, } from "./entwurf-v2-decider.js";
|
|
@@ -38,7 +38,7 @@ import { makeProductionSendViaMailbox } from "./entwurf-v2-mailbox.js";
|
|
|
38
38
|
import { makeNativePushSend } from "./entwurf-v2-native-push.js";
|
|
39
39
|
import { executeControlSocketSend, } from "./entwurf-v2-send.js";
|
|
40
40
|
import { resolveDeadControlSendFallback } from "./entwurf-v2-send-fallback.js";
|
|
41
|
-
import { defaultMetaMailboxDir, defaultMetaSessionsDir, enqueueMetaMessage, metaCapabilityFor, metaRecordExistsByGardenId, readAddressableMetaIdentity, readMetaReceiverMarker, } from "./meta-session.js";
|
|
41
|
+
import { defaultMetaMailboxDir, defaultMetaSessionsDir, enqueueMetaMessage, metaCapabilityFor, metaRecordExistsByGardenId, readAddressableMetaIdentity, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "./meta-session.js";
|
|
42
42
|
import { resolveNativePushAdapter as realResolveNativePushAdapter, } from "./native-push/adapter.js";
|
|
43
43
|
import { CONTROL_SOCKET_DIR, controlSocketPath, inspectControlSocketPath, inspectTargetControlSocket, isRecordLessSocketCandidate, } from "./socket-discovery.js";
|
|
44
44
|
import { classifyConnectError, probeSocketLiveness } from "./socket-probe.js";
|
|
@@ -77,6 +77,8 @@ export function makeProductionEntwurfV2Deps(opts) {
|
|
|
77
77
|
metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
|
|
78
78
|
readIdentity: s.readIdentity ?? readAddressableMetaIdentity,
|
|
79
79
|
readReceiverMarker: s.readReceiverMarker ?? ((gid) => readMetaReceiverMarker({ gardenId: gid })),
|
|
80
|
+
readSenderMarker: s.readSenderMarker ??
|
|
81
|
+
((backend, ownerPid) => readMetaSenderMarker({ backend: requireBackend(backend), ownerPid })),
|
|
80
82
|
inspectPath: s.inspectPath ?? inspectControlSocketPath,
|
|
81
83
|
acquireLock: s.acquireLock ?? realAcquireLock,
|
|
82
84
|
releaseLock: s.releaseLock ?? realReleaseLock,
|
|
@@ -98,22 +100,32 @@ export function makeProductionEntwurfV2Deps(opts) {
|
|
|
98
100
|
enqueue: io.enqueue,
|
|
99
101
|
});
|
|
100
102
|
// ── ONE deliverability seam (SE-2 2d-3): wake-mode capability AND a live active-
|
|
101
|
-
// receiver
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
103
|
+
// receiver. The SAME closure is injected into the decider AND the dead-fallback, so a
|
|
104
|
+
// direct send and a re-resolved fallback send can never drift to different deliverability
|
|
105
|
+
// verdicts. recordBacked is true by construction — resolveTarget already proved the record
|
|
106
|
+
// exists before any unsupported-backend mailbox route, and the closure is only consulted on
|
|
107
|
+
// that route. A null / dead-owner / identity-mismatched marker is fail-closed to inactive
|
|
108
|
+
// (SE-2): a reply to a terminated self-fetch citizen is rejected, not enqueued as mailbox
|
|
109
|
+
// garbage.
|
|
110
|
+
//
|
|
111
|
+
// The two receiver facts come from the SHARED composition (#101 결함 B), never from one
|
|
112
|
+
// value copied into both slots. `ownerAlive` is the marker↔identity match on a live owner;
|
|
113
|
+
// `watchArmed` is the separate measurement that this owner is STILL serving this garden.
|
|
114
|
+
// They were the same expression until a Claude session switch inside one pid left a retired
|
|
115
|
+
// garden's marker reading as an armed doorbell and a real message rotted unread in its
|
|
116
|
+
// mailbox. `entwurf_self` calls the same composition, so a citizen's own replyability can
|
|
117
|
+
// never disagree with what dispatch decided about it. ──────────
|
|
108
118
|
const mailboxDeliverabilityFor = (identity) => {
|
|
109
119
|
const wakeMode = metaCapabilityFor(identity.backend).wakeMode;
|
|
110
|
-
const
|
|
111
|
-
|
|
120
|
+
const { ownerAlive, watchArmed } = resolveMailboxReceiverFacts(identity, {
|
|
121
|
+
readReceiverMarker: io.readReceiverMarker,
|
|
122
|
+
readSenderMarker: io.readSenderMarker,
|
|
123
|
+
});
|
|
112
124
|
return mailboxConversationalDeliverable({
|
|
113
125
|
wakeMode,
|
|
114
126
|
recordBacked: true,
|
|
115
|
-
ownerAlive
|
|
116
|
-
watchArmed
|
|
127
|
+
ownerAlive,
|
|
128
|
+
watchArmed,
|
|
117
129
|
});
|
|
118
130
|
};
|
|
119
131
|
// ── target resolution (QB1 + QB2) ─────────────────────────────────────────
|
|
@@ -103,6 +103,12 @@ export function renderEntwurfV2Result(result) {
|
|
|
103
103
|
`\n ${c.detail}` +
|
|
104
104
|
(c.holder ? `\n holder: pid ${c.holder.pid} on ${c.holder.hostname} since ${c.holder.createdAt}` : "");
|
|
105
105
|
}
|
|
106
|
+
// #101 갭 C: name WHICH receiver axis failed. "mailbox-undeliverable" alone sent a
|
|
107
|
+
// caller looking for a dead session when the session was alive and had simply
|
|
108
|
+
// switched to another garden — the predicate knew that and the surface threw it away.
|
|
109
|
+
if (result.diagnostic?.kind === "mailbox-undeliverable") {
|
|
110
|
+
text += `\n mailbox-undeliverable: ${result.diagnostic.reason}`;
|
|
111
|
+
}
|
|
106
112
|
return { text, isError: true };
|
|
107
113
|
}
|
|
108
114
|
case "executed": {
|
|
@@ -1661,6 +1661,44 @@ export function readMetaReceiverMarker(opts) {
|
|
|
1661
1661
|
return null;
|
|
1662
1662
|
}
|
|
1663
1663
|
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Retire a receiver presence marker THIS owner pid wrote (#101 결함 A).
|
|
1666
|
+
*
|
|
1667
|
+
* A native process that switches sessions in place — an in-session `/resume` or `/clear`
|
|
1668
|
+
* fires a second SessionStart inside the same pid under a new native session id — leaves
|
|
1669
|
+
* the previous garden's marker behind, naming a live owner whose watch is gone. Measured
|
|
1670
|
+
* on oracle 2026-09-04: one pid (143742) held two gardens' markers four seconds apart, and
|
|
1671
|
+
* mail sent to the retired one sat unread.
|
|
1672
|
+
*
|
|
1673
|
+
* ONLY THE MARKER. The meta-record stays: records are identity, and deleting one
|
|
1674
|
+
* outside `meta-bridge-fresh-cut` would fight store certification (Hard Rule 7/8).
|
|
1675
|
+
* A retired citizen keeps its record, its transcript and its listing — it loses only
|
|
1676
|
+
* the claim that a doorbell is armed for it, which is exactly the claim that stopped
|
|
1677
|
+
* being true.
|
|
1678
|
+
*
|
|
1679
|
+
* Fail-closed the other way too: the marker is read back first and removed ONLY when its
|
|
1680
|
+
* `ownerPid` field equals the one passed here — pid equality, which is what a caller can
|
|
1681
|
+
* actually check; it does not prove the same PROCESS wrote it (a reused pid compares equal),
|
|
1682
|
+
* and the reader-side start-key guard is where that finer distinction lives. It is enough for
|
|
1683
|
+
* 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
|
|
1684
|
+
* that cannot happen is a log line, not a broken session start.
|
|
1685
|
+
*/
|
|
1686
|
+
export function removeMetaReceiverMarker(opts) {
|
|
1687
|
+
try {
|
|
1688
|
+
const receiversDir = opts.receiversDir ?? defaultMetaReceiversDir();
|
|
1689
|
+
const file = metaReceiverMarkerPath(opts.gardenId, receiversDir);
|
|
1690
|
+
// verifyOwner:false — the point is the OWNERSHIP field, not the owner's liveness;
|
|
1691
|
+
// a marker whose owner already died is still this pid's to clean up when it names it.
|
|
1692
|
+
const marker = readMetaReceiverMarker({ markerPath: file, verifyOwner: false });
|
|
1693
|
+
if (!marker || marker.ownerPid !== opts.ownerPid)
|
|
1694
|
+
return false;
|
|
1695
|
+
fs.unlinkSync(file);
|
|
1696
|
+
return true;
|
|
1697
|
+
}
|
|
1698
|
+
catch {
|
|
1699
|
+
return false;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1664
1702
|
/**
|
|
1665
1703
|
* Idempotent fs upsert (writes v3 identity). CERTIFY the whole active store, then
|
|
1666
1704
|
* decide create-vs-attach on record EXISTENCE and write atomically as v3. On
|