@junghanacs/entwurf 0.17.2 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -5
- package/CHANGELOG.md +299 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +9 -5
- package/VERIFY.md +3 -3
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +46 -12
- package/docs/setup-clean-host.md +35 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +14 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- 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 +10 -9
- package/pi-extensions/entwurf-control.ts +12 -2
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +3 -3
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- 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 +146 -56
- package/scripts/check-acp-usage-accounting.ts +9 -9
- 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 +63 -12
- package/scripts/check-meta-hook-session-switch.ts +560 -0
- package/scripts/check-release-gate-outcomes.ts +160 -3
- package/scripts/meta-bridge-doctor.sh +29 -4
- package/scripts/mutants/bridge-command-boot.json +3 -3
- package/scripts/mutants/gate-qualification.json +28 -0
- package/scripts/mutants/meta-hook-session-switch.json +223 -0
- package/scripts/mutants/pack-install.json +14 -3
- package/scripts/mutants/release-gate.json +22 -0
- package/scripts/raw-acp-compaction-measure/README.md +77 -0
- package/scripts/raw-acp-compaction-measure/probe.ts +196 -0
- package/scripts/raw-claude-session-switch/README.md +170 -0
- package/scripts/smoke-acp-cortex-live.ts +22 -2
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- 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
|
@@ -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",
|
|
@@ -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
|
|
|
@@ -138,6 +138,12 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
|
|
|
138
138
|
`\n ${c.detail}` +
|
|
139
139
|
(c.holder ? `\n holder: pid ${c.holder.pid} on ${c.holder.hostname} since ${c.holder.createdAt}` : "");
|
|
140
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
|
+
}
|
|
141
147
|
return { text, isError: true };
|
|
142
148
|
}
|
|
143
149
|
case "executed": {
|
|
@@ -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}). */
|
|
@@ -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",
|