@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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +338 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +15 -0
- package/FAQ.md +3 -3
- package/README.md +30 -16
- package/VERIFY.md +14 -11
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +29 -3
- package/docs/setup-clean-host.md +31 -23
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +178 -25
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
- package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
- package/package.json +20 -15
- package/pi/pi-durable/bootstrap.mjs +12 -8
- package/pi/pi-durable/carrier/runtime.js +52 -2
- package/pi/pi-durable/carrier/subagent.js +6 -1
- package/pi/pi-durable/carrier/tui.js +31 -4
- package/pi/pi-durable/carrier-relocation.mjs +2 -2
- package/pi/pi-durable/overlay/upstream-pin.json +79 -36
- package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
- package/pi-extensions/lib/acp/model-ids.js +77 -0
- package/pi-extensions/lib/acp/models.ts +15 -22
- package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/mux-fresh-call.ts +3 -0
- package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
- package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
- package/pi-extensions/lib/resume-launch-identity.ts +12 -4
- package/pi-extensions/meta-bridge-pi-durable.ts +230 -34
- package/run.sh +193 -58
- package/scripts/check-acp-backend-preflight.ts +10 -2
- package/scripts/check-acp-cortex.ts +7 -6
- package/scripts/check-acp-prompt-lifecycle.ts +2 -2
- package/scripts/check-acp-provider-surface.ts +1 -1
- package/scripts/check-acp-session-reuse.ts +5 -5
- package/scripts/check-acp-stop-reason.ts +2 -2
- package/scripts/check-acp-stream-hooks.ts +2 -2
- package/scripts/check-acp-usage-accounting.ts +2 -2
- package/scripts/check-codex-app-server-launch.ts +55 -12
- package/scripts/check-codex-native-push.ts +231 -12
- package/scripts/check-fresh-call-dispatch.ts +47 -4
- package/scripts/check-gate-qualification.ts +8 -6
- package/scripts/check-probe-ordering.ts +13 -6
- package/scripts/check-resume-launch-identity.ts +38 -0
- package/scripts/codex-app-server-launch.sh +54 -29
- package/scripts/codex-socket-classify.ts +46 -0
- package/scripts/emit-pi-durable-carrier.ts +159 -168
- package/scripts/mutants/acp-cortex.json +17 -8
- package/scripts/mutants/acp-set-model-readback.json +28 -0
- package/scripts/mutants/codex-app-server-launch.json +39 -12
- package/scripts/mutants/codex-native.json +15 -0
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/fresh-call-dispatch.json +11 -0
- package/scripts/mutants/herdr-dev-checkout.json +70 -0
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-durable.json +282 -16
- package/scripts/mutants/pi-floor.json +5 -5
- package/scripts/mutants/resume-launch-identity.json +15 -0
- package/scripts/mutants/v2-visible-resume.json +11 -0
- package/scripts/pi-durable-contact-host.mjs +1 -1
- package/scripts/resolve-acp-bridge.ts +1 -1
- package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
- package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
- package/scripts/smoke-acp-long-turn-live.ts +1 -1
- package/scripts/smoke-acp-mcp-live.ts +1 -1
- package/scripts/smoke-acp-memory-containment-live.ts +1 -1
- package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
- package/scripts/smoke-acp-overlay-live.ts +1 -1
- package/scripts/smoke-acp-provider-live.ts +1 -1
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-session-reuse-live.ts +1 -1
- package/scripts/smoke-acp-skill-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +1 -1
- package/scripts/smoke-entwurf-chain-live.ts +1 -1
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- 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
|
|
7
|
-
* models/auth, storage, prompt and recovery. This module is
|
|
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
|
|
59
|
-
*
|
|
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.
|
|
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
|
|
280
|
-
//
|
|
281
|
-
//
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
| {
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
795
|
-
* pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg`
|
|
796
|
-
* the startup pass included — so a `.msg.delivered` left undrained by an earlier host is
|
|
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 (
|
|
895
|
+
if (gated) {
|
|
803
896
|
pending = true;
|
|
804
897
|
return;
|
|
805
898
|
}
|
|
806
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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,
|
|
904
|
-
`${describeError(error)} (cleanup also failed: ${describeError(
|
|
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
|
|
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
|
});
|