@junghanacs/entwurf 0.35.0 → 0.36.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.
Files changed (91) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +338 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/DELIVERY.md +15 -0
  5. package/FAQ.md +3 -3
  6. package/README.md +30 -16
  7. package/VERIFY.md +14 -11
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +29 -3
  10. package/docs/setup-clean-host.md +31 -23
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +178 -25
  23. package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
  24. package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
  25. package/package.json +20 -15
  26. package/pi/pi-durable/bootstrap.mjs +12 -8
  27. package/pi/pi-durable/carrier/runtime.js +52 -2
  28. package/pi/pi-durable/carrier/subagent.js +6 -1
  29. package/pi/pi-durable/carrier/tui.js +31 -4
  30. package/pi/pi-durable/carrier-relocation.mjs +2 -2
  31. package/pi/pi-durable/overlay/upstream-pin.json +79 -36
  32. package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
  33. package/pi-extensions/lib/acp/model-ids.js +77 -0
  34. package/pi-extensions/lib/acp/models.ts +15 -22
  35. package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
  36. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
  37. package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
  38. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  39. package/pi-extensions/lib/mux-fresh-call.ts +3 -0
  40. package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
  41. package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
  42. package/pi-extensions/lib/resume-launch-identity.ts +12 -4
  43. package/pi-extensions/meta-bridge-pi-durable.ts +230 -34
  44. package/run.sh +193 -58
  45. package/scripts/check-acp-backend-preflight.ts +10 -2
  46. package/scripts/check-acp-cortex.ts +7 -6
  47. package/scripts/check-acp-prompt-lifecycle.ts +2 -2
  48. package/scripts/check-acp-provider-surface.ts +1 -1
  49. package/scripts/check-acp-session-reuse.ts +5 -5
  50. package/scripts/check-acp-stop-reason.ts +2 -2
  51. package/scripts/check-acp-stream-hooks.ts +2 -2
  52. package/scripts/check-acp-usage-accounting.ts +2 -2
  53. package/scripts/check-codex-app-server-launch.ts +55 -12
  54. package/scripts/check-codex-native-push.ts +231 -12
  55. package/scripts/check-fresh-call-dispatch.ts +47 -4
  56. package/scripts/check-gate-qualification.ts +8 -6
  57. package/scripts/check-probe-ordering.ts +13 -6
  58. package/scripts/check-resume-launch-identity.ts +38 -0
  59. package/scripts/codex-app-server-launch.sh +54 -29
  60. package/scripts/codex-socket-classify.ts +46 -0
  61. package/scripts/emit-pi-durable-carrier.ts +159 -168
  62. package/scripts/mutants/acp-cortex.json +17 -8
  63. package/scripts/mutants/acp-set-model-readback.json +28 -0
  64. package/scripts/mutants/codex-app-server-launch.json +39 -12
  65. package/scripts/mutants/codex-native.json +15 -0
  66. package/scripts/mutants/control-send-receipt.json +1 -1
  67. package/scripts/mutants/fresh-call-dispatch.json +11 -0
  68. package/scripts/mutants/herdr-dev-checkout.json +70 -0
  69. package/scripts/mutants/pack-install.json +3 -3
  70. package/scripts/mutants/pi-durable.json +282 -16
  71. package/scripts/mutants/pi-floor.json +5 -5
  72. package/scripts/mutants/resume-launch-identity.json +15 -0
  73. package/scripts/mutants/v2-visible-resume.json +11 -0
  74. package/scripts/pi-durable-contact-host.mjs +1 -1
  75. package/scripts/resolve-acp-bridge.ts +1 -1
  76. package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
  77. package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
  78. package/scripts/smoke-acp-long-turn-live.ts +1 -1
  79. package/scripts/smoke-acp-mcp-live.ts +1 -1
  80. package/scripts/smoke-acp-memory-containment-live.ts +1 -1
  81. package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
  82. package/scripts/smoke-acp-overlay-live.ts +1 -1
  83. package/scripts/smoke-acp-provider-live.ts +1 -1
  84. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  85. package/scripts/smoke-acp-session-reuse-live.ts +1 -1
  86. package/scripts/smoke-acp-skill-live.ts +1 -1
  87. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  88. package/scripts/smoke-acp-v2-send-live.ts +1 -1
  89. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  90. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  91. package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * WHAT THIS IS. The long-running durable app (`@earendil-works/pi-coding-agent`
5
5
  * `experimental/durable`, which upstream omits from its npm distribution — Entwurf's package
6
- * supplies it as a pinned carrier, #130, not as an operator prerequisite) owns its runtime, TUI,
7
- * models/auth, storage, prompt and recovery. This module is the whole Entwurf side of its contact:
6
+ * supplies it as a carrier emitted from its owned source, #130/#141, not as an operator
7
+ * prerequisite) owns its runtime, TUI, models/auth, storage, prompt and recovery. This module is
8
+ * the whole Entwurf side of its contact:
8
9
  *
9
10
  * createPiDurableContact({ bridgeEntry })
10
11
  * .extension tools-only durable Extension, handed to the overlay's
@@ -49,14 +50,34 @@
49
50
  * bridge whose list differs: the callable bridge schema is the truth, the copy only has to agree.
50
51
  *
51
52
  * RECEIVE (self-fetch, announce-only). When the bootstrap hands `attach` the overlay's
52
- * `submitToRoot`, the contact arms a doorbell after the bridge is ready: mailbox, then signal,
53
+ * `submitToRoot` and `onFailed`, the contact arms a doorbell after the bridge is ready: mailbox, then signal,
53
54
  * then the watch, then a check that the record and this process's sender marker still name the
54
55
  * session, and only then the receiver marker. A fresh `.msg` is stamped `.msg.delivered` and
55
56
  * announced to the ROOT conversation as input; the body stays in the mailbox until the model
56
57
  * calls `entwurf_inbox_read`, which is the read receipt. The watch and the sender marker share
57
58
  * this process's pid, so the #101 owner join applies (`pi-durable-host`). A watch error, a
58
- * vanished signal, a failed ring, a rejected admission or close retires the doorbell — watch
59
- * closed, our own marker removed, the reason kept by name — and nothing retries it.
59
+ * vanished signal, a failed ring, a rejected admission, the session failing (at once, from the
60
+ * overlay's `onFailed`, not at the next ring — and before `attach` returns when it had already
61
+ * failed, with no receiver marker left behind) or close retires the doorbell — watch closed, our own
62
+ * marker removed, the reason kept by name — and nothing retries it.
63
+ *
64
+ * WITHDRAW (#140). Each notice is a native `followUp`, placed one per run; a read drains EVERY
65
+ * unread body. So after a read the notices still queued for that mail would each cost a turn and an
66
+ * empty read. The contact keeps the native `Submission` handle of each notice it admitted, with the
67
+ * `.msg` names that notice announced, only while that mail is unread. After every
68
+ * `entwurf_inbox_read` attempt — whatever its garden id or outcome — it lists its OWN mailbox once
69
+ * and withdraws, through the native `Submission.abort` and the read's own invocation Context, each
70
+ * notice none of whose names is still `.msg.delivered`. That per-notice file predicate is the only
71
+ * authority: no garden-id guard, no requestId lookup, no queue of our own, no rewritten text. A placed
72
+ * or settled notice answers so and is let go; a withdraw that fails is let go too, named in one
73
+ * stderr line and on the receiver report, never retried and never retiring the doorbell; a mailbox
74
+ * that cannot be listed proves nothing absent, so every notice is kept and that is named the same
75
+ * way. The read's own result or error is returned unchanged — no diagnostic joins it. Ring passes
76
+ * and reads share one gate: a read waits for an admission in flight (else its drain could beat the
77
+ * handle it must withdraw), and an arrival during a read is rung once the read lets go. The count
78
+ * in a notice is its admission-time snapshot. Not covered: notices admitted before a restart (no
79
+ * handle), mail drained by another process (until this contact's next read), and operator abort,
80
+ * which natively withdraws queued notices while their mail stays unread.
60
81
  *
61
82
  * VISIBLE IDENTITY (a static projection). `withGardenIdentity` hands the native TUI a view whose
62
83
  * every conversation label carries this citizen's garden id, so the footer the TUI keeps on screen
@@ -79,7 +100,8 @@
79
100
  *
80
101
  * NOT HERE: crash-safe receive. `entwurf_inbox_read` archives a body before the durable tool
81
102
  * result commits, so a crash between the two loses that body to the model; recovery settles the
82
- * call as interrupted and no doorbell rings again. No resume.
103
+ * call as interrupted and no doorbell rings again. A drain that fails part-way has archived the
104
+ * bodies before it (the bridge returns its error), and those too never reach the model. No resume.
83
105
  */
84
106
 
85
107
  import * as fs from "node:fs";
@@ -276,9 +298,14 @@ export interface PiDurableRoots {
276
298
  // ---------------------------------------------------------------------------
277
299
  // Durable surface, typed NARROWLY and locally — read at pi-durable 1.0.2 @cd32f77 (#129,
278
300
  // `harness/types.ts`; historical coordinates, not re-anchored here). The current supply is the
279
- // carrier `pi/pi-durable/overlay/upstream-pin.json` names (1.1.0 at `abe508e`). `[read 2026-10-08]`
280
- // v1.0.4 → v1.1.0 `harness/types.ts` adds `models`, `contextRetentionMs` and a `context()` options
281
- // bag only; none of the shapes typed below moved.
301
+ // carrier emitted from Entwurf's owned source (pi/pi-durable/src, #141) against the 1.2.0 SDK set
302
+ // `pi/pi-durable/overlay/upstream-pin.json` names (origin `abe508e`). `[read 2026-10-13]` at
303
+ // v1.2.0 one shape typed below moved: `ToolExecutionResult.content` is now `output`, beside the new
304
+ // `structuredOutput` (`harness/types.ts`, its CHANGELOG's breaking changes). An omitted `output`
305
+ // gives the model the text a tool retained through `api.output()`; `content` is not read, so a
306
+ // result naming only `content` that retained no output reaches the model as nothing. The contact's
307
+ // tools retain none, so they answer in `output`.
308
+ // The submission draft, its `abort()` answers and the identity `define*` helpers did not move.
282
309
  // `defineExtension`/`defineTool` are identity functions (`harness/define.ts:6-17`), so a plain
283
310
  // object is the extension.
284
311
  // ---------------------------------------------------------------------------
@@ -295,8 +322,9 @@ export interface DurableCallContext {
295
322
  readonly abortSignal?: AbortSignal;
296
323
  }
297
324
 
325
+ /** `ToolExecutionResult`, narrowed: `output` is what the model reads. */
298
326
  export interface DurableToolResult {
299
- content: { type: "text"; text: string }[];
327
+ output: { type: "text"; text: string }[];
300
328
  }
301
329
 
302
330
  export interface DurableTool {
@@ -530,22 +558,32 @@ export interface DurableDoorbellDraft {
530
558
  readonly requestId: string;
531
559
  }
532
560
 
533
- /** The part of the native `Submission` handle a doorbell records; admission is not completion. */
561
+ /** What the native `Submission.abort` answers: only a still-queued input is withdrawn. */
562
+ export type DurableWithdrawal = "aborted" | "already_placed" | "settled";
563
+
564
+ /** The part of the native `Submission` handle a doorbell keeps; admission is not completion. */
534
565
  export interface DurableAdmittedSubmission {
535
566
  readonly id: unknown;
567
+ /** Native `Submission.abort(context)`, handed the read's own invocation Context unchanged. */
568
+ abort(context: DurableCallContext | undefined): Promise<DurableWithdrawal>;
536
569
  }
537
570
 
538
571
  /** The overlay's `OpenDurableResult.submitToRoot`. */
539
572
  export type DurableSubmitToRoot = (draft: DurableDoorbellDraft) => Promise<DurableAdmittedSubmission>;
540
573
 
574
+ /** The overlay's `OpenDurableResult.onFailed`: one synchronous call when the session fails and closes itself. */
575
+ export type DurableOnFailed = (listener: () => void) => () => void;
576
+
541
577
  /** What `attach` needs to arm the doorbell. Without it the citizen only sends. */
542
578
  export interface DurableRootAdmission {
543
579
  submitToRoot: DurableSubmitToRoot;
580
+ onFailed: DurableOnFailed;
544
581
  }
545
582
 
546
583
  /** Why an armed doorbell was retired. Nothing re-arms it. */
547
584
  export type PiDurableReceiverRetirement =
548
585
  | "closed"
586
+ | "session-failed"
549
587
  | "watch-error"
550
588
  | "signal-vanished"
551
589
  | "doorbell-failed"
@@ -562,21 +600,40 @@ export interface PiDurableDoorbellAdmission {
562
600
  unread: number;
563
601
  }
564
602
 
603
+ /**
604
+ * The LATEST notice withdrawal that failed. `inspection-failed`: the own mailbox could not be listed,
605
+ * so no notice was judged and every one is kept. `abort-failed`: the native withdraw of the notice
606
+ * named by `requestId` rejected; that notice is let go, not retried.
607
+ */
608
+ export interface PiDurableWithdrawalFailure {
609
+ reason: "inspection-failed" | "abort-failed";
610
+ requestId?: string;
611
+ detail: string;
612
+ }
613
+
565
614
  /**
566
615
  * The receiver's diagnostic. `admissions` is the LATEST successful admission only — an empty array
567
616
  * before the first ring, then exactly one entry, replaced by each later success; a retirement keeps
568
617
  * the last one. It is a diagnostic, not a history: it never grows with the host's lifetime, and its
569
618
  * length is no count of how often the root was asked (the root's own submissions are that record).
619
+ * `withdrawalFailure`, present only once a withdrawal failed, is likewise the latest one only.
570
620
  */
571
621
  export type PiDurableReceiverReport =
572
622
  | { status: "off" }
573
- | { status: "armed"; gardenId: string; markerPath: string; admissions: PiDurableDoorbellAdmission[] }
623
+ | {
624
+ status: "armed";
625
+ gardenId: string;
626
+ markerPath: string;
627
+ admissions: PiDurableDoorbellAdmission[];
628
+ withdrawalFailure?: PiDurableWithdrawalFailure;
629
+ }
574
630
  | {
575
631
  status: "retired";
576
632
  gardenId: string;
577
633
  reason: PiDurableReceiverRetirement;
578
634
  detail: string;
579
635
  admissions: PiDurableDoorbellAdmission[];
636
+ withdrawalFailure?: PiDurableWithdrawalFailure;
580
637
  };
581
638
 
582
639
  export interface PiDurableAttachment {
@@ -587,7 +644,7 @@ export interface PiDurableAttachment {
587
644
  /** The roots written — and the roots the bridge child was bound to. */
588
645
  roots: PiDurableRoots;
589
646
  bridgePid: number | null;
590
- /** The receiver marker this attach published, or null when no root admission was handed in. */
647
+ /** The receiver marker this attach published; null when no root admission was handed in, or the session had already failed. */
591
648
  receiverMarkerPath: string | null;
592
649
  }
593
650
 
@@ -639,6 +696,14 @@ interface ArmedDoorbell {
639
696
  markerPath: string;
640
697
  watcher: fs.FSWatcher;
641
698
  submitToRoot: DurableSubmitToRoot;
699
+ unsubscribeFailed: () => void;
700
+ }
701
+
702
+ /** One admitted notice: its native handle and the `.msg` names it announced. */
703
+ interface HeldNotice {
704
+ readonly requestId: string;
705
+ readonly fresh: readonly string[];
706
+ readonly submission: DurableAdmittedSubmission;
642
707
  }
643
708
 
644
709
  const TOOL_DESCRIPTIONS: Record<PiDurableToolName, string> = {
@@ -730,9 +795,10 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
730
795
  throw new Error(`${name} arguments must be an object`);
731
796
  }
732
797
  const client = await waitReady(context?.abortSignal);
733
- const result = await client.callTool(name, (args ?? {}) as Record<string, unknown>, context?.abortSignal);
798
+ const call = () => client.callTool(name, (args ?? {}) as Record<string, unknown>, context?.abortSignal);
799
+ const result = name === "entwurf_inbox_read" ? await readInbox(call, context) : await call();
734
800
  if (result.isError) throw new Error(result.text || `${name} failed`);
735
- return { content: [{ type: "text", text: result.text }] };
801
+ return { output: [{ type: "text", text: result.text }] };
736
802
  },
737
803
  });
738
804
 
@@ -745,8 +811,34 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
745
811
  let doorbell: ArmedDoorbell | null = null;
746
812
  let report: PiDurableReceiverReport = { status: "off" };
747
813
  const admissions: PiDurableDoorbellAdmission[] = [];
748
- let ringing = false;
814
+ /** The armed mailbox, kept past a retirement so notices admitted before it can still be judged. */
815
+ let inbox: string | null = null;
816
+ /** Every admitted notice still owed a withdraw judgement, oldest first. */
817
+ const notices: HeldNotice[] = [];
818
+ let withdrawalFailure: PiDurableWithdrawalFailure | undefined;
819
+ // One gate for ring passes and inbox reads; `pending` is a signal that landed while it was held.
820
+ let gated = false;
749
821
  let pending = false;
822
+ const gateWaiters: (() => void)[] = [];
823
+
824
+ const enterGate = (): Promise<void> => {
825
+ if (!gated) {
826
+ gated = true;
827
+ return Promise.resolve();
828
+ }
829
+ return new Promise<void>((resolve) => gateWaiters.push(resolve));
830
+ };
831
+
832
+ /** Hand the gate to the next waiting read, else open it — and ring once for a signal that landed meanwhile. */
833
+ const leaveGate = (): void => {
834
+ const next = gateWaiters.shift();
835
+ if (next !== undefined) {
836
+ next();
837
+ return;
838
+ }
839
+ gated = false;
840
+ if (pending) void ring("signal");
841
+ };
750
842
 
751
843
  /**
752
844
  * Give the doorbell back: close the watch and remove the marker, in one step, because a
@@ -759,6 +851,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
759
851
  const armed = doorbell;
760
852
  if (armed === null) return [];
761
853
  doorbell = null;
854
+ armed.unsubscribeFailed();
762
855
  const problems: string[] = [];
763
856
  try {
764
857
  armed.watcher.close();
@@ -791,19 +884,19 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
791
884
  /**
792
885
  * One contract with the other self-fetch doorbells: a fresh `*.msg` is the trigger, it is
793
886
  * stamped `.msg.delivered` before the announce, and the announced count is every
794
- * `.msg.delivered`. Events that land while an admission is in flight coalesce into one more
795
- * pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg` rings —
796
- * the startup pass included — so a `.msg.delivered` left undrained by an earlier host is not
797
- * announced again on its own; it shows only in the count of the next fresh arrival.
887
+ * `.msg.delivered`. Events that land while an admission or an inbox read holds the gate coalesce
888
+ * into one more pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg`
889
+ * rings — the startup pass included — so a `.msg.delivered` left undrained by an earlier host is
890
+ * not announced again on its own; it shows only in the count of the next fresh arrival.
798
891
  */
799
892
  const ring = async (why: PiDurableDoorbellAdmission["why"]): Promise<void> => {
800
893
  const armed = doorbell;
801
894
  if (armed === null) return;
802
- if (ringing) {
895
+ if (gated) {
803
896
  pending = true;
804
897
  return;
805
898
  }
806
- ringing = true;
899
+ gated = true;
807
900
  try {
808
901
  do {
809
902
  pending = false;
@@ -836,11 +929,71 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
836
929
  }
837
930
  // Latest only: the diagnostic names the LAST success and never grows with the host's lifetime.
838
931
  admissions.splice(0, admissions.length, { why, requestId, submissionId: submission.id, fresh, unread });
932
+ notices.push({ requestId, fresh, submission });
839
933
  } while (pending && doorbell === armed);
840
934
  } catch (error) {
841
935
  retire("doorbell-failed", describeError(error));
842
936
  } finally {
843
- ringing = false;
937
+ leaveGate();
938
+ }
939
+ };
940
+
941
+ /**
942
+ * One named stderr line per failed withdrawal — stderr ALWAYS, as `entwurf-control` does, since
943
+ * nothing in the native app reads the receiver report — and the report keeps the latest one.
944
+ */
945
+ const noteWithdrawalFailure = (failure: PiDurableWithdrawalFailure): void => {
946
+ withdrawalFailure = failure;
947
+ const subject = failure.requestId === undefined ? "" : ` ${failure.requestId}`;
948
+ const detail = failure.detail.replace(/\s+/g, " ");
949
+ process.stderr.write(`[entwurf pi-durable] notice withdrawal ${failure.reason}${subject}: ${detail}\n`);
950
+ };
951
+
952
+ /**
953
+ * Withdraw every held notice none of whose announced `.msg` names is still `.msg.delivered` in the
954
+ * armed mailbox, listed once. A mailbox that cannot be listed proves nothing absent: every notice
955
+ * is kept and that is named. A judged notice is let go whatever the native withdraw answers — or
956
+ * when it rejects, which is named and never retried — so no handle outlives its mail by more
957
+ * than one read. Never throws: the read's own answer is what the caller gets.
958
+ */
959
+ const withdrawDrained = async (context: DurableCallContext | undefined): Promise<void> => {
960
+ if (inbox === null || notices.length === 0) return;
961
+ let unread: Set<string>;
962
+ try {
963
+ unread = new Set(fs.readdirSync(inbox).filter((name) => name.endsWith(".msg.delivered")));
964
+ } catch (error) {
965
+ noteWithdrawalFailure({ reason: "inspection-failed", detail: `${inbox}: ${describeError(error)}` });
966
+ return;
967
+ }
968
+ for (const notice of [...notices]) {
969
+ if (notice.fresh.some((name) => unread.has(`${name}.delivered`))) continue;
970
+ notices.splice(notices.indexOf(notice), 1);
971
+ try {
972
+ await notice.submission.abort(context);
973
+ } catch (error) {
974
+ noteWithdrawalFailure({ reason: "abort-failed", requestId: notice.requestId, detail: describeError(error) });
975
+ }
976
+ }
977
+ };
978
+
979
+ /**
980
+ * An inbox read under the doorbell's gate, then the withdrawal its drain made due — after a
981
+ * failed read too, whose partial drain may already have archived mail. The bridge's own answer,
982
+ * result or rejection, is returned unchanged.
983
+ */
984
+ const readInbox = async (
985
+ call: () => Promise<BridgeCallResult>,
986
+ context: DurableCallContext | undefined,
987
+ ): Promise<BridgeCallResult> => {
988
+ await enterGate();
989
+ try {
990
+ return await call();
991
+ } finally {
992
+ try {
993
+ await withdrawDrained(context);
994
+ } finally {
995
+ leaveGate();
996
+ }
844
997
  }
845
998
  };
846
999
 
@@ -848,15 +1001,16 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
848
1001
  * Mailbox, signal, watch — then the check that the record and this process's sender marker
849
1002
  * still name the session — and only then the receiver marker, the one fact a sender reads.
850
1003
  * Synchronous, so nothing can close the contact between the check and the publish. Throws,
851
- * after closing whatever it opened, when any step fails.
1004
+ * after closing whatever it opened, when any step fails. Returns null when the session had
1005
+ * already failed: the doorbell was retired as `session-failed` before this returned.
852
1006
  */
853
1007
  const arm = (
854
1008
  gardenId: string,
855
1009
  nativeSessionId: string,
856
1010
  ownerPid: number,
857
1011
  roots: PiDurableRoots,
858
- submitToRoot: DurableSubmitToRoot,
859
- ): string => {
1012
+ root: DurableRootAdmission,
1013
+ ): string | null => {
860
1014
  if (ownerPid !== process.pid) {
861
1015
  throw new Error(
862
1016
  `the doorbell's watch lives in this process (${process.pid}); a receiver marker naming owner ${ownerPid} would advertise a watch that pid does not hold`,
@@ -867,6 +1021,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
867
1021
  fs.mkdirSync(mailbox, { recursive: true });
868
1022
  if (!fs.existsSync(signal)) fs.writeFileSync(signal, "", { mode: 0o600 });
869
1023
  const watcher = watch(signal, () => void ring("signal"));
1024
+ let unsubscribeFailed = (): void => {};
870
1025
  try {
871
1026
  watcher.on("error", (error) => retire("watch-error", describeError(error)));
872
1027
  const identity = readAddressableMetaIdentity(gardenId, roots.sessionsDir);
@@ -883,6 +1038,21 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
883
1038
  : `the sender marker for owner ${ownerPid} names ${sender.gardenId}/${sender.nativeSessionId}, not ${gardenId}/${nativeSessionId}`,
884
1039
  );
885
1040
  }
1041
+ // A failed session admits nothing: retire at once, so no sender reads a marker for a host that cannot wake.
1042
+ // Subscribed before the marker is written, so a registration that throws leaves no marker. A session that
1043
+ // failed before this arm says so during the subscription, before the doorbell exists: that is noted, and
1044
+ // the retirement runs once the doorbell is published below. It is final; nothing re-arms.
1045
+ let published = false;
1046
+ let failedBeforePublished = false;
1047
+ const sessionFailed = (): void => {
1048
+ if (!published) {
1049
+ failedBeforePublished = true;
1050
+ return;
1051
+ }
1052
+ const problems = retire("session-failed", "the durable session failed and closed itself");
1053
+ if (problems.length > 0) throw new Error(`the doorbell did not retire cleanly: ${problems.join("; ")}`);
1054
+ };
1055
+ unsubscribeFailed = root.onFailed(sessionFailed);
886
1056
  const markerPath = writeMetaReceiverMarker({
887
1057
  gardenId,
888
1058
  backend: PI_DURABLE_BACKEND,
@@ -892,16 +1062,37 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
892
1062
  armProvenance: "session-start",
893
1063
  receiversDir: roots.receiversDir,
894
1064
  });
895
- doorbell = { gardenId, ownerPid, mailbox, signal, markerPath, watcher, submitToRoot };
1065
+ doorbell = {
1066
+ gardenId,
1067
+ ownerPid,
1068
+ mailbox,
1069
+ signal,
1070
+ markerPath,
1071
+ watcher,
1072
+ submitToRoot: root.submitToRoot,
1073
+ unsubscribeFailed,
1074
+ };
1075
+ inbox = mailbox;
896
1076
  report = { status: "armed", gardenId, markerPath, admissions };
1077
+ published = true;
1078
+ if (failedBeforePublished) {
1079
+ sessionFailed();
1080
+ return null;
1081
+ }
897
1082
  return markerPath;
898
1083
  } catch (error) {
899
- try {
900
- watcher.close();
901
- } catch (closeError) {
1084
+ const cleanup: unknown[] = [];
1085
+ for (const step of [unsubscribeFailed, () => watcher.close()]) {
1086
+ try {
1087
+ step();
1088
+ } catch (cleanupError) {
1089
+ cleanup.push(cleanupError);
1090
+ }
1091
+ }
1092
+ if (cleanup.length > 0) {
902
1093
  throw new AggregateError(
903
- [error, closeError],
904
- `${describeError(error)} (cleanup also failed: ${describeError(closeError)})`,
1094
+ [error, ...cleanup],
1095
+ `${describeError(error)} (cleanup also failed: ${cleanup.map(describeError).join("; ")})`,
905
1096
  );
906
1097
  }
907
1098
  throw error;
@@ -930,6 +1121,9 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
930
1121
  if (root !== undefined && typeof root.submitToRoot !== "function") {
931
1122
  throw new Error("the root admission handed to attach has no submitToRoot function");
932
1123
  }
1124
+ if (root !== undefined && typeof root.onFailed !== "function") {
1125
+ throw new Error("the root admission handed to attach has no onFailed function");
1126
+ }
933
1127
  const ownerPid = options.ownerPid ?? process.pid;
934
1128
  const sendersDir = path.resolve(options.sendersDir ?? defaultMetaSendersDir());
935
1129
  const upsert = upsertMetaSession({
@@ -981,7 +1175,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
981
1175
  let receiverMarkerPath: string | null = null;
982
1176
  if (root !== undefined) {
983
1177
  try {
984
- receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root.submitToRoot);
1178
+ receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root);
985
1179
  } catch (error) {
986
1180
  return await failAfterCleanup(new Error(`the doorbell did not arm: ${describeError(error)}`), () =>
987
1181
  client.close(),
@@ -1011,13 +1205,15 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
1011
1205
  settleFailed(describeError(error));
1012
1206
  },
1013
1207
  receiver() {
1014
- return report;
1208
+ return withdrawalFailure === undefined || report.status === "off" ? report : { ...report, withdrawalFailure };
1015
1209
  },
1016
1210
  async close() {
1017
1211
  const previous = state;
1018
1212
  settleFailed("the contact closed before attach completed");
1019
1213
  state = { kind: "closed" };
1020
1214
  const client = previous.kind === "ready" ? previous.client : undefined;
1215
+ // Queued notices stay native (a continued session places them); this contact judges no more.
1216
+ notices.length = 0;
1021
1217
  await closeAll(disarmOnClose, async () => {
1022
1218
  await client?.close();
1023
1219
  });