@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
|
@@ -109,6 +109,110 @@ export function receiverMarkerMatchesIdentity(
|
|
|
109
109
|
);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// ── the watch-owner ↔ sender-marker join (#101 결함 B) ──────────────────────
|
|
113
|
+
// A receiver marker proves "a LIVE process once armed a watch for this garden".
|
|
114
|
+
// It does NOT prove "that process is serving this garden RIGHT NOW". One native
|
|
115
|
+
// process can hold markers for several gardens and drain exactly one of them:
|
|
116
|
+
// Claude Code's session switch (an in-session `/resume` or `/clear`) fires a second
|
|
117
|
+
// SessionStart inside the SAME pid under a NEW native session id, so the first
|
|
118
|
+
// garden's marker keeps naming a live owner forever while the doorbell it advertises
|
|
119
|
+
// is gone. Measured on oracle 2026-09-04 (#101): one pid held both markers, a
|
|
120
|
+
// 09:33 enqueue to the retired garden sat unread as a bare `.msg` for ≥50 minutes —
|
|
121
|
+
// the "enqueue into a void" this module's header exists to refuse.
|
|
122
|
+
//
|
|
123
|
+
// The join that decides it is already on disk: the SENDER marker is keyed by owner
|
|
124
|
+
// pid and rewritten with the CURRENT garden on every arm/prompt, so
|
|
125
|
+
// `meta-senders/<backend>/<ownerPid>.json.gardenId` is the answer to "which garden
|
|
126
|
+
// does this process serve now?". A receiver marker naming a different garden than
|
|
127
|
+
// its own owner's sender marker is a retired watch.
|
|
128
|
+
//
|
|
129
|
+
// SCOPE IS NOT UNIVERSAL, AND THE MARKER SAYS SO. The join only exists where the
|
|
130
|
+
// watch owner IS the process the sender marker is keyed to. That is true for the
|
|
131
|
+
// Claude hook (one `ownerPid` variable writes both markers —
|
|
132
|
+
// `meta-bridge-hook.ts` sender + receiver arm) and false BY CONSTRUCTION for
|
|
133
|
+
// Copilot, whose watch lives in a forked first-party extension child
|
|
134
|
+
// (`extension.mjs` writes `ownerPid: process.pid`) while its sender marker is keyed
|
|
135
|
+
// to the CLI parent (`meta-bridge-hook-copilot.ts` uses `process.ppid`). Applying
|
|
136
|
+
// the join there would make every Copilot citizen permanently undeliverable — a
|
|
137
|
+
// regression on a shipped lane, not a fix. `ownerKind` is recorded on the marker for
|
|
138
|
+
// exactly this reason ("the marker records which, because the pid a reader verifies
|
|
139
|
+
// differs" — AGENTS.md, self-fetch domain), so it is the axis, not `backend`.
|
|
140
|
+
//
|
|
141
|
+
// `omp-host` is a CANDIDATE, deliberately not admitted here: OMP already retires the
|
|
142
|
+
// previous garden in-process on its `/new` edge (the unarm the claude hook lacked),
|
|
143
|
+
// so the cell this join closes has a different owner there. Admitting it needs its
|
|
144
|
+
// own measurement, not this file's optimism.
|
|
145
|
+
export const SENDER_JOINED_RECEIVER_OWNER_KINDS: readonly string[] = ["claude-code-cli"];
|
|
146
|
+
|
|
147
|
+
/** Does this watch owner share its pid with the backend's sender marker? */
|
|
148
|
+
export function receiverOwnerKindJoinsSender(ownerKind: string): boolean {
|
|
149
|
+
return SENDER_JOINED_RECEIVER_OWNER_KINDS.includes(ownerKind);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The receiver-marker fields the join reads (a structural shape, like ReceiverIdentityFacts). */
|
|
153
|
+
export interface ReceiverOwnerFacts extends ReceiverIdentityFacts {
|
|
154
|
+
ownerPid: number;
|
|
155
|
+
ownerKind: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The sender-marker fields the join reads: which garden this owner pid serves NOW. */
|
|
159
|
+
export interface SenderOwnerFacts {
|
|
160
|
+
gardenId: string;
|
|
161
|
+
backend: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Is the watch owner still serving THIS garden? Fail-closed inside the join's scope:
|
|
166
|
+
* an absent/unreadable sender marker, or one naming another garden or backend, means
|
|
167
|
+
* the watch is retired. Outside the scope (an ownerKind whose watch owner is not the
|
|
168
|
+
* sender-marker process) the join does not apply and the marker's own live-owner guard
|
|
169
|
+
* is the whole rule — returning true here is NOT optimism, it is "this axis says
|
|
170
|
+
* nothing", and the caller has already required the marker to match the identity.
|
|
171
|
+
*/
|
|
172
|
+
export function receiverOwnerServesGarden(
|
|
173
|
+
marker: ReceiverOwnerFacts,
|
|
174
|
+
senderMarker: SenderOwnerFacts | null | undefined,
|
|
175
|
+
): boolean {
|
|
176
|
+
if (!receiverOwnerKindJoinsSender(marker.ownerKind)) return true;
|
|
177
|
+
return !!senderMarker && senderMarker.gardenId === marker.gardenId && senderMarker.backend === marker.backend;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The two marker readers the mailbox receiver facts are composed from (injected — this module does no IO). */
|
|
181
|
+
export interface MailboxReceiverReaders {
|
|
182
|
+
/** Read the receiver presence marker for a garden id (null = absent/corrupt/dead owner). */
|
|
183
|
+
readReceiverMarker: (gardenId: string) => ReceiverOwnerFacts | null;
|
|
184
|
+
/** Read the sender marker for an owner pid (null = absent/corrupt/dead owner). */
|
|
185
|
+
readSenderMarker: (backend: string, ownerPid: number) => SenderOwnerFacts | null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* THE single composition of the two mailbox receiver facts, over injected readers.
|
|
190
|
+
* Both production consumers — the v2 `mailboxDeliverabilityFor` seam and the MCP
|
|
191
|
+
* bridge's `entwurf_self` — call THIS, so a direct send, a re-resolved fallback send
|
|
192
|
+
* and a citizen's own replyability can never drift to different verdicts.
|
|
193
|
+
*
|
|
194
|
+
* ownerAlive — a live-owner marker that names THIS identity (the reader already ran
|
|
195
|
+
* the plausibility + start-key guards; this adds the identity match).
|
|
196
|
+
* watchArmed — that owner is still serving this garden (the join above). It is a
|
|
197
|
+
* MEASUREMENT, never a copy of ownerAlive: copying it is what let a
|
|
198
|
+
* retired watch read as an armed doorbell (#101 결함 B).
|
|
199
|
+
*
|
|
200
|
+
* `recordBacked` is NOT decided here — it stays the caller's explicit fact, so an
|
|
201
|
+
* absent record and a dead owner stay distinguishable in the reason string.
|
|
202
|
+
*/
|
|
203
|
+
export function resolveMailboxReceiverFacts(
|
|
204
|
+
identity: ReceiverIdentityFacts,
|
|
205
|
+
readers: MailboxReceiverReaders,
|
|
206
|
+
): { ownerAlive: boolean; watchArmed: boolean } {
|
|
207
|
+
const marker = readers.readReceiverMarker(identity.gardenId);
|
|
208
|
+
const ownerAlive = receiverMarkerMatchesIdentity(marker, identity);
|
|
209
|
+
if (!ownerAlive || !marker) return { ownerAlive: false, watchArmed: false };
|
|
210
|
+
const senderMarker = receiverOwnerKindJoinsSender(marker.ownerKind)
|
|
211
|
+
? readers.readSenderMarker(marker.backend, marker.ownerPid)
|
|
212
|
+
: null;
|
|
213
|
+
return { ownerAlive, watchArmed: receiverOwnerServesGarden(marker, senderMarker) };
|
|
214
|
+
}
|
|
215
|
+
|
|
112
216
|
export interface MailboxDeliverabilityFacts extends MetaReceiverActiveFacts {
|
|
113
217
|
/** The target backend's wake mode (from the capability registry). */
|
|
114
218
|
wakeMode?: WakeMode | string;
|
|
@@ -27,7 +27,13 @@
|
|
|
27
27
|
* one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
type FactList,
|
|
32
|
+
isOutOfSocketDomainGardenIdConflict,
|
|
33
|
+
type PeerObserver,
|
|
34
|
+
resolveFactList,
|
|
35
|
+
} from "./entwurf-facts.ts";
|
|
36
|
+
import { observePeerFacts } from "./entwurf-peer-observe.ts";
|
|
31
37
|
import { isLivenessSupported } from "./entwurf-v2-contract.ts";
|
|
32
38
|
import {
|
|
33
39
|
type ActiveStoreEntry,
|
|
@@ -93,6 +99,12 @@ export interface EntwurfFactsDeps {
|
|
|
93
99
|
readRecord: (filename: string) => string;
|
|
94
100
|
/** Socket axis: injected into scanSocketProbes (controlDir/readdir/probe). */
|
|
95
101
|
socket?: Partial<SocketScanDeps>;
|
|
102
|
+
/** Observation axis (#101): per-citizen receiver + transcript facts. Defaults to the
|
|
103
|
+
* REAL measurement — the same seam shape `makeProductionEntwurfV2Deps` uses, so the two
|
|
104
|
+
* wiring sites (MCP + pi-native) cannot drift by each passing their own observer, and a
|
|
105
|
+
* gate still drives the assembly with a fake and no filesystem. A caller that injects
|
|
106
|
+
* nothing gets facts; a caller that injects `() => UNOBSERVED_PEER` says so on every row. */
|
|
107
|
+
observe?: PeerObserver;
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
function diagnosticSortKey(d: EntwurfDiagnostic): string {
|
|
@@ -113,10 +125,12 @@ function diagnosticSortKey(d: EntwurfDiagnostic): string {
|
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
/**
|
|
116
|
-
* Assemble the facts-only listing.
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
128
|
+
* Assemble the facts-only listing. Driven entirely through injected deps, so the gate
|
|
129
|
+
* runs it without a filesystem; slice 4c supplies the real readdir / readFile / probe.
|
|
130
|
+
* The one dep with a REAL default is `observe` (#101) — see its comment: a per-citizen
|
|
131
|
+
* measurement both wiring sites must make identically is safer as one default than as two
|
|
132
|
+
* call-site arguments. Probes are gid + liveness only (#50 C4 — the per-socket get_info
|
|
133
|
+
* enrich left with the socket-only quasi-citizen listing).
|
|
120
134
|
*/
|
|
121
135
|
export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfFactsResult> {
|
|
122
136
|
const diagnostics: EntwurfDiagnostic[] = [];
|
|
@@ -183,7 +197,7 @@ export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfF
|
|
|
183
197
|
// fire as the last line of defense, never caught here.
|
|
184
198
|
const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
|
|
185
199
|
const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
|
|
186
|
-
const facts: FactList = resolveFactList(cleanIdentities, cleanProbes);
|
|
200
|
+
const facts: FactList = resolveFactList(cleanIdentities, cleanProbes, deps.observe ?? observePeerFacts);
|
|
187
201
|
|
|
188
202
|
// 5. #50 C4 demotion: a record-less socket is a diagnostic, not a listing
|
|
189
203
|
// section. One diagnostic per socket (subjects aggregate at render, F8);
|
|
@@ -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
|
|
@@ -47,8 +50,44 @@ export interface PeerFact {
|
|
|
47
50
|
recordUpdatedAt: string;
|
|
48
51
|
// — the single computed fact: 4-value liveness (R1/R3b). NOT a verb. —
|
|
49
52
|
liveness: FactLiveness;
|
|
53
|
+
// — observed state (#101). Facts, not verbs: they say what was found on disk, not
|
|
54
|
+
// what a caller may do about it. For claude-code every row reads
|
|
55
|
+
// `liveness=unsupported` — the control-socket probe does not apply — so the listing
|
|
56
|
+
// showed nothing that separated a live citizen from a phantom, and a caller picking
|
|
57
|
+
// "the newest record in this cwd" picked the phantom as often as the real one.
|
|
58
|
+
receiver: ReceiverObservation;
|
|
59
|
+
transcript: TranscriptObservation;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The mailbox receiver axis, as OBSERVED for this citizen.
|
|
64
|
+
* active a presence marker whose owner is live AND still serving this garden.
|
|
65
|
+
* inactive a marker exists but fails that test (dead owner, drifted identity, or an
|
|
66
|
+
* owner that switched to another garden).
|
|
67
|
+
* none no marker at all — never armed, or retired.
|
|
68
|
+
* n/a this backend has no mailbox receiver axis (pi, antigravity).
|
|
69
|
+
* unobserved nobody measured it. Only reachable when a caller drives the fact core
|
|
70
|
+
* without an observer; it is the honest word for "not looked at", never a
|
|
71
|
+
* quiet stand-in for `none`.
|
|
72
|
+
*/
|
|
73
|
+
export type ReceiverObservation = "active" | "inactive" | "none" | "n/a" | "unobserved";
|
|
74
|
+
|
|
75
|
+
/** Does the recorded transcript exist on disk? `absent` is what a never-used registration
|
|
76
|
+
* looks like: a record with no conversation behind it. */
|
|
77
|
+
export type TranscriptObservation = "exists" | "absent" | "unobserved";
|
|
78
|
+
|
|
79
|
+
export interface PeerObservations {
|
|
80
|
+
receiver: ReceiverObservation;
|
|
81
|
+
transcript: TranscriptObservation;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What a caller that measured nothing must say. Explicit and greppable — a row that
|
|
85
|
+
* silently defaulted to `none`/`absent` would be a fabricated fact. */
|
|
86
|
+
export const UNOBSERVED_PEER: PeerObservations = { receiver: "unobserved", transcript: "unobserved" };
|
|
87
|
+
|
|
88
|
+
/** Measure the two observed axes for one citizen. Injected, so this module stays pure. */
|
|
89
|
+
export type PeerObserver = (identity: MetaIdentity) => PeerObservations;
|
|
90
|
+
|
|
52
91
|
/**
|
|
53
92
|
* Compose a `PeerFact` from a citizen's identity and an optional socket probe.
|
|
54
93
|
*
|
|
@@ -61,7 +100,11 @@ export interface PeerFact {
|
|
|
61
100
|
*
|
|
62
101
|
* Pure: same inputs → same output, no IO.
|
|
63
102
|
*/
|
|
64
|
-
export function resolvePeerFact(
|
|
103
|
+
export function resolvePeerFact(
|
|
104
|
+
identity: MetaIdentity,
|
|
105
|
+
socket: SocketLiveness | null,
|
|
106
|
+
observations: PeerObservations = UNOBSERVED_PEER,
|
|
107
|
+
): PeerFact {
|
|
65
108
|
return {
|
|
66
109
|
gardenId: identity.gardenId,
|
|
67
110
|
backend: identity.backend,
|
|
@@ -71,6 +114,8 @@ export function resolvePeerFact(identity: MetaIdentity, socket: SocketLiveness |
|
|
|
71
114
|
createdAt: identity.createdAt,
|
|
72
115
|
recordUpdatedAt: identity.recordUpdatedAt,
|
|
73
116
|
liveness: factLivenessOf(identity.backend, socket),
|
|
117
|
+
receiver: observations.receiver,
|
|
118
|
+
transcript: observations.transcript,
|
|
74
119
|
};
|
|
75
120
|
}
|
|
76
121
|
|
|
@@ -172,7 +217,11 @@ export interface FactList {
|
|
|
172
217
|
* A gardenId is never emitted as both a `PeerFact` and a `RecordLessSocketFact`;
|
|
173
218
|
* a record-less socket becomes a `PeerFact` the moment a record claims the gid.
|
|
174
219
|
*/
|
|
175
|
-
export function resolveFactList(
|
|
220
|
+
export function resolveFactList(
|
|
221
|
+
identities: MetaIdentity[],
|
|
222
|
+
socketProbes: SocketProbe[],
|
|
223
|
+
observe: PeerObserver = () => UNOBSERVED_PEER,
|
|
224
|
+
): FactList {
|
|
176
225
|
const probeMap = new Map<string, SocketProbe>();
|
|
177
226
|
for (const probe of socketProbes) {
|
|
178
227
|
if (probeMap.has(probe.gardenId)) {
|
|
@@ -207,7 +256,7 @@ export function resolveFactList(identities: MetaIdentity[], socketProbes: Socket
|
|
|
207
256
|
}
|
|
208
257
|
socket = null;
|
|
209
258
|
}
|
|
210
|
-
peers.push(resolvePeerFact(identity, socket));
|
|
259
|
+
peers.push(resolvePeerFact(identity, socket, observe(identity)));
|
|
211
260
|
consumed.add(gid);
|
|
212
261
|
}
|
|
213
262
|
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import { resolveMailboxReceiverFacts } from "./entwurf-deliverability.ts";
|
|
25
|
+
import type { PeerObservations, ReceiverObservation, TranscriptObservation } from "./entwurf-facts.ts";
|
|
26
|
+
import {
|
|
27
|
+
type MetaBackend,
|
|
28
|
+
type MetaIdentity,
|
|
29
|
+
metaCapabilityFor,
|
|
30
|
+
metaReceiverMarkerPath,
|
|
31
|
+
readMetaReceiverMarker,
|
|
32
|
+
readMetaSenderMarker,
|
|
33
|
+
requireBackend,
|
|
34
|
+
} from "./meta-session.ts";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The mailbox receiver axis for one citizen.
|
|
38
|
+
*
|
|
39
|
+
* `n/a` is not a failure: a backend with no mailbox (pi's control socket, antigravity's
|
|
40
|
+
* native push) has no receiver marker to be right or wrong about, and printing `none`
|
|
41
|
+
* there would invent a missing thing. The `inactive` / `none` split is what a reader
|
|
42
|
+
* needs to tell "a watch was armed and is no longer valid" from "no watch was ever
|
|
43
|
+
* armed here", so it is taken from the marker FILE's existence rather than from the
|
|
44
|
+
* reader's null — the reader folds absent, corrupt and dead-owner into one null.
|
|
45
|
+
*/
|
|
46
|
+
function observeReceiver(identity: MetaIdentity): ReceiverObservation {
|
|
47
|
+
if (metaCapabilityFor(identity.backend).wakeMode !== "self-fetch") return "n/a";
|
|
48
|
+
const facts = resolveMailboxReceiverFacts(identity, {
|
|
49
|
+
readReceiverMarker: (gardenId: string) => readMetaReceiverMarker({ gardenId }),
|
|
50
|
+
readSenderMarker: (backend: string, ownerPid: number) =>
|
|
51
|
+
readMetaSenderMarker({ backend: requireBackend(backend) as MetaBackend, ownerPid }),
|
|
52
|
+
});
|
|
53
|
+
if (facts.ownerAlive && facts.watchArmed) return "active";
|
|
54
|
+
return fs.existsSync(metaReceiverMarkerPath(identity.gardenId)) ? "inactive" : "none";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Does the recorded transcript exist? A record is minted at SessionStart, but a harness
|
|
59
|
+
* that writes its transcript lazily (Claude Code writes on the first turn) leaves a
|
|
60
|
+
* citizen with no conversation behind it until someone actually says something. The PATH
|
|
61
|
+
* stays private — only its existence crosses to the listing.
|
|
62
|
+
*/
|
|
63
|
+
function observeTranscript(identity: MetaIdentity): TranscriptObservation {
|
|
64
|
+
try {
|
|
65
|
+
// A record with no recorded transcript path has nothing to stat — that is the same
|
|
66
|
+
// observable state as a path that is not there, and neither is an `exists` claim.
|
|
67
|
+
return identity.transcriptPath && fs.existsSync(identity.transcriptPath) ? "exists" : "absent";
|
|
68
|
+
} catch {
|
|
69
|
+
// A transcript we cannot stat is not a transcript we can claim exists.
|
|
70
|
+
return "absent";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The production observer: both axes, measured for one citizen. */
|
|
75
|
+
export function observePeerFacts(identity: MetaIdentity): PeerObservations {
|
|
76
|
+
return { receiver: observeReceiver(identity), transcript: observeTranscript(identity) };
|
|
77
|
+
}
|
|
@@ -39,9 +39,20 @@ export interface EntwurfPeersRender {
|
|
|
39
39
|
payload: EntwurfPeersPayload;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// `receiver` and `transcript` are OBSERVED facts, not routing (#101): they say what was
|
|
43
|
+
// found on disk for this citizen, and a caller still asks entwurf_v2 what may be done
|
|
44
|
+
// about it. They earn a column because for every claude-code row `liveness` reads
|
|
45
|
+
// `unsupported` — the socket probe does not apply — so two rows in one cwd, a live
|
|
46
|
+
// conversation and an abandoned startup session whose transcript was never written, used to
|
|
47
|
+
// be indistinguishable here. `receiver=active|inactive|none|n/a` is a projection of the
|
|
48
|
+
// same measurement dispatch uses; `transcript=exists|absent` is the record's transcript,
|
|
49
|
+
// existence only, never the path.
|
|
42
50
|
function renderPeerLine(p: PeerFact): string {
|
|
43
51
|
const model = p.model ?? "(unknown)";
|
|
44
|
-
return
|
|
52
|
+
return (
|
|
53
|
+
`- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} receiver=${p.receiver} ` +
|
|
54
|
+
`transcript=${p.transcript} cwd=${p.cwd} model=${model}`
|
|
55
|
+
);
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
function renderDiagnosticLine(d: EntwurfDiagnostic): string {
|
|
@@ -128,13 +128,24 @@ export type ExecutionPlan =
|
|
|
128
128
|
// claim so 5c's at-most-once re-resolve runs under the same nonce) and null for the
|
|
129
129
|
// lock-free meta-mailbox path (?7).
|
|
130
130
|
//
|
|
131
|
-
// A reject's optional machine-readable diagnostic
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
131
|
+
// A reject's optional machine-readable diagnostic, riding ALONGSIDE the receipt — the
|
|
132
|
+
// receipt schema is unchanged and 5d's surface renders the diagnostic onto the reject.
|
|
133
|
+
// Two kinds, both here for the same reason: the evidence that decided the reject was
|
|
134
|
+
// produced one layer down and would otherwise be dropped at this boundary, leaving the
|
|
135
|
+
// caller a verdict with no way to see or clear its cause (F2-P2 "관측 가능해야 수용").
|
|
136
|
+
//
|
|
137
|
+
// target-locked the `LockConflict` (holder pid/host/createdAt, lockPath, human
|
|
138
|
+
// detail) the lock primitive produced on contention. B3: without
|
|
139
|
+
// it a PID-reuse permanent lock could not be observed/cleared.
|
|
140
|
+
// mailbox-undeliverable WHICH receiver axis failed, in the deliverability predicate's own
|
|
141
|
+
// words — no backing record vs. a dead owner vs. a watch that is no
|
|
142
|
+
// longer armed are three different situations with three different
|
|
143
|
+
// fixes, and the bare `mailbox-undeliverable` reason told a caller
|
|
144
|
+
// none of them (#101 갭 C). The predicate already computes the
|
|
145
|
+
// sentence; this carries it instead of discarding it.
|
|
146
|
+
export type RejectDiagnostic =
|
|
147
|
+
| { kind: "target-locked"; conflict: LockConflict }
|
|
148
|
+
| { kind: "mailbox-undeliverable"; reason: string };
|
|
138
149
|
|
|
139
150
|
export type DispatchDecision =
|
|
140
151
|
| { kind: "reject"; receipt: RejectReceipt; diagnostic?: RejectDiagnostic }
|
|
@@ -327,7 +338,14 @@ export async function decideDispatch(input: DispatchInput, deps: DispatchDecider
|
|
|
327
338
|
// fail-closed (SE-2 2d-3). resolveDispatch then routes intent × deliverable.
|
|
328
339
|
const deliverability = await deps.mailboxDeliverabilityFor(identity);
|
|
329
340
|
const receipt = resolveDispatch(input.intent, "unsupported", deliverability.deliverable);
|
|
330
|
-
|
|
341
|
+
// The predicate's reason travels with the reject (#101 갭 C). It is attached only when
|
|
342
|
+
// undeliverability is what produced the reject — an intent-shaped refusal on a
|
|
343
|
+
// DELIVERABLE target must not be dressed up as a receiver problem.
|
|
344
|
+
if (!receipt.ok) {
|
|
345
|
+
return deliverability.deliverable
|
|
346
|
+
? reject(receipt)
|
|
347
|
+
: reject(receipt, { kind: "mailbox-undeliverable", reason: deliverability.reason });
|
|
348
|
+
}
|
|
331
349
|
// the only allow cell here is fire-and-forget → meta-mailbox send.
|
|
332
350
|
const plan: ExecutionPlan = {
|
|
333
351
|
transport: "meta-mailbox",
|
|
@@ -50,6 +50,13 @@ export interface MetaMailboxSendDeps {
|
|
|
50
50
|
* legacy mailbox path used). `plan.wantsReply` is threaded into the body — v2 carries the
|
|
51
51
|
* caller's intent, a DELIBERATE divergence from the legacy hard-coded `false`. An enqueue
|
|
52
52
|
* throw PROPAGATES; it is never converted to `{success:false}`.
|
|
53
|
+
*
|
|
54
|
+
* #98 R — the SEND receipt: `enqueueMetaMessage` already returns the exact `.msg` path it
|
|
55
|
+
* wrote ("Returns the paths so a sender can show exactly what was queued"), and this body
|
|
56
|
+
* used to flatten it to `{success:true}`. It now carries that ONE field through. Nothing
|
|
57
|
+
* else from the enqueue result crosses: no timestamps, and specifically no `lastReadAt` —
|
|
58
|
+
* see the `RpcSendResult` note. The path is reported verbatim from the enqueue result, not
|
|
59
|
+
* re-derived from the plan, so it can never disagree with the file that was actually written.
|
|
53
60
|
*/
|
|
54
61
|
export function executeMetaMailboxSend(
|
|
55
62
|
plan: MetaMailboxPlan,
|
|
@@ -57,13 +64,13 @@ export function executeMetaMailboxSend(
|
|
|
57
64
|
deps: MetaMailboxSendDeps,
|
|
58
65
|
): RpcSendResult {
|
|
59
66
|
const body = sender ? formatMetaMailboxBody(sender, plan.message, plan.wantsReply) : plan.message;
|
|
60
|
-
deps.enqueue({
|
|
67
|
+
const enqueued = deps.enqueue({
|
|
61
68
|
gardenId: plan.targetGardenId,
|
|
62
69
|
body,
|
|
63
70
|
sessionsDir: plan.sessionsDir,
|
|
64
71
|
mailboxDir: plan.mailboxDir,
|
|
65
72
|
});
|
|
66
|
-
return { success: true };
|
|
73
|
+
return { success: true, messagePath: enqueued.messagePath };
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
/**
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
import {
|
|
40
40
|
type MailboxDeliverabilityResult,
|
|
41
41
|
mailboxConversationalDeliverable,
|
|
42
|
-
|
|
42
|
+
resolveMailboxReceiverFacts,
|
|
43
43
|
} from "./entwurf-deliverability.ts";
|
|
44
44
|
import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.ts";
|
|
45
45
|
import { isLivenessSupported } from "./entwurf-v2-contract.ts";
|
|
@@ -75,10 +75,13 @@ import {
|
|
|
75
75
|
enqueueMetaMessage,
|
|
76
76
|
type MetaIdentity,
|
|
77
77
|
type MetaReceiverMarker,
|
|
78
|
+
type MetaSenderMarker,
|
|
78
79
|
metaCapabilityFor,
|
|
79
80
|
metaRecordExistsByGardenId,
|
|
80
81
|
readAddressableMetaIdentity,
|
|
81
82
|
readMetaReceiverMarker,
|
|
83
|
+
readMetaSenderMarker,
|
|
84
|
+
requireBackend,
|
|
82
85
|
} from "./meta-session.ts";
|
|
83
86
|
import {
|
|
84
87
|
type NativePushAdapter,
|
|
@@ -111,6 +114,10 @@ export interface ProductionEntwurfV2Seams {
|
|
|
111
114
|
* SE-2 2d-3 active-receiver source; the factory's `mailboxDeliverabilityFor` closure verifies
|
|
112
115
|
* its identity match. */
|
|
113
116
|
readReceiverMarker: (gardenId: string) => MetaReceiverMarker | null;
|
|
117
|
+
/** Read the sender marker keyed to a receiver's owner pid — the "which garden does this
|
|
118
|
+
* process serve NOW?" fact the #101 watch-owner join reads. Null = absent / dead owner /
|
|
119
|
+
* corrupt, which the join treats as a retired watch inside its scope. */
|
|
120
|
+
readSenderMarker: (backend: string, ownerPid: number) => MetaSenderMarker | null;
|
|
114
121
|
/** Record-side lstat of the EXACT target socket path (no connect) for the pre-probe conflict. */
|
|
115
122
|
inspectPath: (socketPath: string) => Promise<TargetSocketInspection>;
|
|
116
123
|
acquireLock: (gid: string, deps: { dir?: string }) => AcquireLockResult;
|
|
@@ -183,6 +190,9 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
|
|
|
183
190
|
metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
|
|
184
191
|
readIdentity: s.readIdentity ?? readAddressableMetaIdentity,
|
|
185
192
|
readReceiverMarker: s.readReceiverMarker ?? ((gid: string) => readMetaReceiverMarker({ gardenId: gid })),
|
|
193
|
+
readSenderMarker:
|
|
194
|
+
s.readSenderMarker ??
|
|
195
|
+
((backend: string, ownerPid: number) => readMetaSenderMarker({ backend: requireBackend(backend), ownerPid })),
|
|
186
196
|
inspectPath: s.inspectPath ?? inspectControlSocketPath,
|
|
187
197
|
acquireLock: s.acquireLock ?? realAcquireLock,
|
|
188
198
|
releaseLock: s.releaseLock ?? realReleaseLock,
|
|
@@ -207,22 +217,32 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
|
|
|
207
217
|
});
|
|
208
218
|
|
|
209
219
|
// ── ONE deliverability seam (SE-2 2d-3): wake-mode capability AND a live active-
|
|
210
|
-
// receiver
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
220
|
+
// receiver. The SAME closure is injected into the decider AND the dead-fallback, so a
|
|
221
|
+
// direct send and a re-resolved fallback send can never drift to different deliverability
|
|
222
|
+
// verdicts. recordBacked is true by construction — resolveTarget already proved the record
|
|
223
|
+
// exists before any unsupported-backend mailbox route, and the closure is only consulted on
|
|
224
|
+
// that route. A null / dead-owner / identity-mismatched marker is fail-closed to inactive
|
|
225
|
+
// (SE-2): a reply to a terminated self-fetch citizen is rejected, not enqueued as mailbox
|
|
226
|
+
// garbage.
|
|
227
|
+
//
|
|
228
|
+
// The two receiver facts come from the SHARED composition (#101 결함 B), never from one
|
|
229
|
+
// value copied into both slots. `ownerAlive` is the marker↔identity match on a live owner;
|
|
230
|
+
// `watchArmed` is the separate measurement that this owner is STILL serving this garden.
|
|
231
|
+
// They were the same expression until a Claude session switch inside one pid left a retired
|
|
232
|
+
// garden's marker reading as an armed doorbell and a real message rotted unread in its
|
|
233
|
+
// mailbox. `entwurf_self` calls the same composition, so a citizen's own replyability can
|
|
234
|
+
// never disagree with what dispatch decided about it. ──────────
|
|
217
235
|
const mailboxDeliverabilityFor = (identity: MetaIdentity): MailboxDeliverabilityResult => {
|
|
218
236
|
const wakeMode = metaCapabilityFor(identity.backend).wakeMode;
|
|
219
|
-
const
|
|
220
|
-
|
|
237
|
+
const { ownerAlive, watchArmed } = resolveMailboxReceiverFacts(identity, {
|
|
238
|
+
readReceiverMarker: io.readReceiverMarker,
|
|
239
|
+
readSenderMarker: io.readSenderMarker,
|
|
240
|
+
});
|
|
221
241
|
return mailboxConversationalDeliverable({
|
|
222
242
|
wakeMode,
|
|
223
243
|
recordBacked: true,
|
|
224
|
-
ownerAlive
|
|
225
|
-
watchArmed
|
|
244
|
+
ownerAlive,
|
|
245
|
+
watchArmed,
|
|
226
246
|
});
|
|
227
247
|
};
|
|
228
248
|
|
|
@@ -61,12 +61,17 @@ export interface DispatchExecutorDeps {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/** The per-transport success outcome, discriminated by transport so the surface renders
|
|
64
|
-
* each without guessing. `control-socket` carries the optional N3 `rejectReason
|
|
64
|
+
* each without guessing. `control-socket` carries the optional N3 `rejectReason` and,
|
|
65
|
+
* when a dead-socket re-resolve fell back to the mailbox, that leg's #98 R `messagePath`;
|
|
65
66
|
* `meta-mailbox` is always `success:true` (enqueue has no in-band refuse — a failure is
|
|
66
|
-
* a throw, handled as `execution-failed`)
|
|
67
|
+
* a throw, handled as `execution-failed`) and carries the #98 R send receipt: the path of
|
|
68
|
+
* the `.msg` the enqueue actually wrote. OPTIONAL because a fake/legacy `sendMailbox` dep
|
|
69
|
+
* may omit it — a missing receipt must degrade the rendered line, never fail the delivery,
|
|
70
|
+
* and it is the ONLY enqueue-side datum carried (never a read timestamp; see
|
|
71
|
+
* `RpcSendResult`). */
|
|
67
72
|
export type ExecutedOutcome =
|
|
68
|
-
| { transport: "control-socket"; outcome: SendFinalOutcome; rejectReason?: string }
|
|
69
|
-
| { transport: "meta-mailbox"; success: true }
|
|
73
|
+
| { transport: "control-socket"; outcome: SendFinalOutcome; rejectReason?: string; messagePath?: string }
|
|
74
|
+
| { transport: "meta-mailbox"; success: true; messagePath?: string }
|
|
70
75
|
// native-push carries `retried` so the surface can note the 1-shot re-probe retry fired.
|
|
71
76
|
| { transport: "native-push"; success: true; retried: boolean };
|
|
72
77
|
|
|
@@ -118,7 +123,12 @@ export async function executeDispatch(
|
|
|
118
123
|
kind: "executed",
|
|
119
124
|
receipt,
|
|
120
125
|
transport,
|
|
121
|
-
outcome: {
|
|
126
|
+
outcome: {
|
|
127
|
+
transport: "control-socket",
|
|
128
|
+
outcome: r.outcome,
|
|
129
|
+
rejectReason: r.rejectReason,
|
|
130
|
+
messagePath: r.messagePath,
|
|
131
|
+
},
|
|
122
132
|
};
|
|
123
133
|
} catch (err) {
|
|
124
134
|
// N1: a delivered/refused send whose release then threw — lock dirty, do NOT retry.
|
|
@@ -150,7 +160,14 @@ export async function executeDispatch(
|
|
|
150
160
|
"entwurf-v2-runner: meta-mailbox send returned success:false (contract violation; a mailbox has no in-band reject).",
|
|
151
161
|
);
|
|
152
162
|
}
|
|
153
|
-
return {
|
|
163
|
+
return {
|
|
164
|
+
kind: "executed",
|
|
165
|
+
receipt,
|
|
166
|
+
transport,
|
|
167
|
+
// #98 R: carry the enqueue receipt verbatim. `undefined` stays `undefined` —
|
|
168
|
+
// the runner never substitutes a guessed path for a missing one.
|
|
169
|
+
outcome: { transport: "meta-mailbox", success: true, messagePath: r.messagePath },
|
|
170
|
+
};
|
|
154
171
|
} catch (err) {
|
|
155
172
|
return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
|
|
156
173
|
}
|
|
@@ -52,10 +52,20 @@ export type MetaMailboxPlan = Extract<ExecutionPlan, { transport: "meta-mailbox"
|
|
|
52
52
|
export type SendFinalOutcome = "sent" | "fallback-sent" | "rejected" | "failed";
|
|
53
53
|
|
|
54
54
|
/** What a single RPC / mailbox enqueue reports. `success:false` is an in-band reject
|
|
55
|
-
* (the receiver answered and refused) — distinct from a thrown connect error.
|
|
55
|
+
* (the receiver answered and refused) — distinct from a thrown connect error.
|
|
56
|
+
*
|
|
57
|
+
* `messagePath` is the SEND-side receipt (#98 R) and is OPTIONAL because only the
|
|
58
|
+
* mailbox rail has one: an enqueue writes a file, so there is a per-message artifact
|
|
59
|
+
* to name. A control-socket RPC hands the body to a live receiver and produces no
|
|
60
|
+
* file, so it leaves this undefined rather than inventing one. It carries the ENQUEUED
|
|
61
|
+
* path and NOTHING about reading: `lastReadAt` at enqueue time is the PREVIOUS
|
|
62
|
+
* message's read stamp, so surfacing it here would read as "my message was read" —
|
|
63
|
+
* the exact misreading #98 opened on. The per-message read receipt is the `.read`
|
|
64
|
+
* suffix on this very file, never a state.json slot. */
|
|
56
65
|
export interface RpcSendResult {
|
|
57
66
|
success: boolean;
|
|
58
67
|
error?: string;
|
|
68
|
+
messagePath?: string;
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
/** The same-lock one-shot re-resolve result (5c-2b implements the resolver; 5c-2a
|
|
@@ -98,6 +108,14 @@ export interface ControlSocketSendResult {
|
|
|
98
108
|
* 5d runner carries this verbatim so the surface can tell "in-band refusal" from
|
|
99
109
|
* "no live route" — the N3 carry-over the hand boundary used to drop. */
|
|
100
110
|
rejectReason?: string;
|
|
111
|
+
/** #98 R, fallback leg: the `.msg` a dead-socket re-resolve enqueued. Present ONLY
|
|
112
|
+
* when the fallback actually routed to the mailbox and the enqueue succeeded — a
|
|
113
|
+
* control-socket retry hands the body to a live receiver and writes no file, so it
|
|
114
|
+
* leaves this undefined rather than inventing one. Same rule as `RpcSendResult`: the
|
|
115
|
+
* ENQUEUED path and nothing about reading. Without it a `fallback-sent` is the one
|
|
116
|
+
* mailbox delivery whose sender gets no per-message identifier — the same letter the
|
|
117
|
+
* primary mailbox rail names. */
|
|
118
|
+
messagePath?: string;
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
// A drive step's verdict: the terminal outcome, plus the original error to RETHROW on
|
|
@@ -107,6 +125,8 @@ interface SendDrive {
|
|
|
107
125
|
outcome: SendFinalOutcome;
|
|
108
126
|
error?: unknown;
|
|
109
127
|
rejectReason?: string;
|
|
128
|
+
/** #98 R receipt from the mailbox fallback leg (see `ControlSocketSendResult`). */
|
|
129
|
+
messagePath?: string;
|
|
110
130
|
}
|
|
111
131
|
|
|
112
132
|
/**
|
|
@@ -139,7 +159,7 @@ export async function executeControlSocketSend(
|
|
|
139
159
|
drive = { outcome: "failed", error: err };
|
|
140
160
|
}
|
|
141
161
|
finalizeRelease(policy, deps, held, drive);
|
|
142
|
-
return { outcome: drive.outcome, rejectReason: drive.rejectReason };
|
|
162
|
+
return { outcome: drive.outcome, rejectReason: drive.rejectReason, messagePath: drive.messagePath };
|
|
143
163
|
}
|
|
144
164
|
|
|
145
165
|
/** Drive the 1차 send and route a connect failure through the F3 split. */
|
|
@@ -208,7 +228,10 @@ async function driveDeadFallback(
|
|
|
208
228
|
// hand never reaches for the mailbox on its own; only the resolver routes here.
|
|
209
229
|
try {
|
|
210
230
|
const r = await deps.sendViaMailbox(rePlan, lock);
|
|
211
|
-
|
|
231
|
+
// #98 R: this leg writes a `.msg` exactly like the primary mailbox rail, so it
|
|
232
|
+
// owes the sender the same per-message receipt. Carried only on success — a
|
|
233
|
+
// `rejected` enqueue wrote no file to name.
|
|
234
|
+
return r.success ? { outcome: "fallback-sent", messagePath: r.messagePath } : { outcome: "rejected" };
|
|
212
235
|
} catch (err) {
|
|
213
236
|
return { outcome: "failed", error: err };
|
|
214
237
|
}
|