@junghanacs/entwurf 0.34.2 → 0.35.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/CHANGELOG.md +244 -0
- package/README.md +7 -0
- package/VERIFY.md +16 -5
- package/demo/README.md +109 -175
- package/demo/demo-baseline.sh +5 -1
- package/demo/demo.sh +6 -2
- package/demo/herdr-pi-cc-20261011.provenance.json +64 -0
- package/docs/acp-backend-rail.md +28 -2
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/setup-clean-host.md +1 -1
- 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/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/resume-launch-identity.js +13 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +119 -11
- package/package.json +4 -2
- 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/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/resume-launch-identity.ts +12 -4
- package/pi-extensions/meta-bridge-pi-durable.ts +153 -13
- package/run.sh +118 -20
- 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-fresh-call-dispatch.ts +47 -4
- package/scripts/check-gate-qualification.ts +8 -5
- package/scripts/check-probe-ordering.ts +13 -6
- package/scripts/check-release-gate-outcomes.ts +159 -1
- package/scripts/check-resume-launch-identity.ts +38 -0
- package/scripts/ci-qualify-decide.sh +114 -17
- package/scripts/mutants/acp-cortex.json +17 -8
- package/scripts/mutants/acp-set-model-readback.json +28 -0
- package/scripts/mutants/demo-reproducibility.json +155 -0
- package/scripts/mutants/fresh-call-dispatch.json +11 -0
- package/scripts/mutants/herdr-dev-checkout.json +70 -0
- package/scripts/mutants/pi-durable.json +117 -0
- package/scripts/mutants/release-gate.json +28 -2
- package/scripts/mutants/resume-launch-identity.json +15 -0
- package/scripts/mutants/v2-visible-resume.json +11 -0
- 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/scripts/tsconfig.json +1 -0
|
@@ -33,9 +33,18 @@
|
|
|
33
33
|
// re-export of `getBuiltinModels`. When pi removes compat we migrate to whatever
|
|
34
34
|
// the loader then exposes.
|
|
35
35
|
import { getModels } from "@earendil-works/pi-ai/compat";
|
|
36
|
+
import { CURATED_ANCHOR_MODEL_ID, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./model-ids.js";
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// The provider id and the curated ID lists live in the dependency-free
|
|
39
|
+
// `model-ids.js` leaf, shared with the launch seams (#128); this file is the only
|
|
40
|
+
// one that loads the upstream catalog to turn them into registry rows.
|
|
41
|
+
export {
|
|
42
|
+
CORTEX_MODEL_PREFIX,
|
|
43
|
+
CURATED_ANCHOR_MODEL_ID,
|
|
44
|
+
PROVIDER_ID,
|
|
45
|
+
SUPPORTED_ANTHROPIC_MODEL_IDS,
|
|
46
|
+
SUPPORTED_CORTEX_MODEL_IDS,
|
|
47
|
+
} from "./model-ids.js";
|
|
39
48
|
|
|
40
49
|
// #26 auth-boundary sentinel. `pi.registerProvider` requires an apiKey when a
|
|
41
50
|
// provider defines custom models, but the ACP plugin consumes NO key: backend
|
|
@@ -49,17 +58,8 @@ export const PROVIDER_ID = "entwurf";
|
|
|
49
58
|
// check-auth-boundary gate pins this.
|
|
50
59
|
export const ENTWURF_ACP_NO_AUTH_SENTINEL = "entwurf-no-auth";
|
|
51
60
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// Exported so the claude backend adapter (backend-adapter.ts) can answer
|
|
55
|
-
// `routeModel` without re-deriving the set from curatedClaudeModels().
|
|
56
|
-
export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-5", "claude-fable-5-1"] as const;
|
|
57
|
-
|
|
58
|
-
/** The anchor model whose absence is a hard registry regression, not a soft skip. */
|
|
59
|
-
export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5";
|
|
60
|
-
|
|
61
|
-
// Anthropic's registry reports 1M for Sonnet 5, Opus 5 and Fable 5.1, and the
|
|
62
|
-
// entwurf surface exposes the full 1M for all three. Sonnet 5's 1M window is the
|
|
61
|
+
// Anthropic's registry reports 1M for Sonnet 5.5, Opus 5.5 and Fable 5.1, and the
|
|
62
|
+
// entwurf surface exposes the full 1M for all three. Sonnet 5's 1M window was the
|
|
63
63
|
// whole point of the 0.12.3 bump — it is the compact-free long-context floor the
|
|
64
64
|
// earlier 200K Sonnet cap could not provide. We still clamp to a 1M ceiling so a
|
|
65
65
|
// future registry value can't silently inflate the surface past what we verify.
|
|
@@ -92,7 +92,7 @@ function claudeContextWindow(model: { id: string; contextWindow: number }): numb
|
|
|
92
92
|
// carries the reserved `cortex-` prefix: the prefix is the SINGLE routing
|
|
93
93
|
// authority (backend-adapter.ts `routeModel`) and keeps the ids off the Claude
|
|
94
94
|
// native ids Cortex routes to (`cortex-claude-sonnet-5` vs the unprefixed
|
|
95
|
-
// `claude-sonnet-5` the claude adapter owns). `routeModel` strips the prefix to
|
|
95
|
+
// `claude-sonnet-5-5` the claude adapter owns). `routeModel` strips the prefix to
|
|
96
96
|
// recover the native id, which is enforced per-turn via
|
|
97
97
|
// session/set_config_option — never a launch `-m` pin (CP0-M).
|
|
98
98
|
//
|
|
@@ -101,14 +101,7 @@ function claudeContextWindow(model: { id: string; contextWindow: number }): numb
|
|
|
101
101
|
// the running cortex no longer serves fails LOUD at set-model, before the
|
|
102
102
|
// prompt (measured: `Unsupported model: …` names the live set). Adding an id
|
|
103
103
|
// here is the same verify-both-axes commitment as the Claude set — do not
|
|
104
|
-
// extend casually.
|
|
105
|
-
export const CORTEX_MODEL_PREFIX = "cortex-";
|
|
106
|
-
export const SUPPORTED_CORTEX_MODEL_IDS = [
|
|
107
|
-
"cortex-auto",
|
|
108
|
-
"cortex-claude-opus-5",
|
|
109
|
-
"cortex-claude-sonnet-5",
|
|
110
|
-
"cortex-openai-gpt-5.4",
|
|
111
|
-
] as const;
|
|
104
|
+
// extend casually. The prefix and the four ids are declared in model-ids.js.
|
|
112
105
|
|
|
113
106
|
// Hand-set conservative context window. Cortex reports the LIVE window via its
|
|
114
107
|
// own ACP session config, not this curated metadata — this is a floor for the
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
* outcome for this module to claim.
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
|
+
import { ACP_MODEL_NOT_CURATED_HINT, AcpModelNotCuratedError } from "./acp/model-ids.js";
|
|
50
51
|
import { buildResumePiArgs } from "./entwurf-resume-args.ts";
|
|
51
52
|
import {
|
|
52
53
|
type AcquireLockResult,
|
|
@@ -93,6 +94,7 @@ export const RESUME_OBSERVE_INTERVAL_MS = 500;
|
|
|
93
94
|
export type VisibleResumeRejectReason =
|
|
94
95
|
| "target-invalid"
|
|
95
96
|
| "target-not-pi"
|
|
97
|
+
| "acp-model-not-curated"
|
|
96
98
|
| "target-locked"
|
|
97
99
|
| "target-live"
|
|
98
100
|
| "target-indeterminate"
|
|
@@ -227,6 +229,15 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
|
|
|
227
229
|
try {
|
|
228
230
|
identity = deps.resolveIdentity(targetGardenId);
|
|
229
231
|
} catch (err) {
|
|
232
|
+
// #128: the recorded model is not an exact curated ACP id, so Pi would pick another row
|
|
233
|
+
// by substring. Same shape as `target-not-pi`: a named refusal matched on its FIELD.
|
|
234
|
+
if (err instanceof AcpModelNotCuratedError) {
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
reason: err.reason,
|
|
238
|
+
detail: `${targetGardenId} would launch model ${JSON.stringify(err.model)} under the entwurf provider (the resolved launch pair, which may differ from the recorded provider)`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
230
241
|
if (err instanceof ResumeBackendUnsupportedError) {
|
|
231
242
|
return {
|
|
232
243
|
ok: false,
|
|
@@ -321,6 +332,7 @@ export async function observeSameGidSocket(
|
|
|
321
332
|
const REJECT_HINT: Record<VisibleResumeRejectReason, string> = {
|
|
322
333
|
"target-invalid": "no citizen has that id; discover targets with entwurf_peers",
|
|
323
334
|
"target-not-pi": "that citizen's backend has no same-id resume — reach it with entwurf_v2, or open a fresh sibling",
|
|
335
|
+
"acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
|
|
324
336
|
"target-locked": "another resume or dispatch holds this citizen's lock right now",
|
|
325
337
|
"target-live": "that citizen is already running — this verb only reopens dormant ones",
|
|
326
338
|
"target-indeterminate": "the socket probe could not prove the citizen is dormant, so nothing was started",
|
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { spawn as spawnChildProcess } from "node:child_process";
|
|
28
|
+
import { ACP_MODEL_NOT_CURATED, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
|
|
28
29
|
import { codexCallerFreshPreflight, codexFreshPreflight } from "./codex-fresh-preflight.ts";
|
|
29
|
-
import { mintNonce } from "./fresh-call-composition.ts";
|
|
30
|
+
import { isSafeFreshCallModel, mintNonce } from "./fresh-call-composition.ts";
|
|
30
31
|
import {
|
|
31
32
|
createHerdrRunner,
|
|
32
33
|
type HerdrFreshCallResult,
|
|
@@ -67,6 +68,23 @@ export type DispatchedFreshCall =
|
|
|
67
68
|
| { readonly rail: "tmux"; readonly result: FreshCallResult }
|
|
68
69
|
| { readonly rail: "herdr"; readonly result: HerdrFreshCallResult };
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* #128: a pi sibling asked for under the `entwurf` provider must name an EXACT curated ACP id. Pi
|
|
73
|
+
* resolves `--model` by substring among that provider's rows, so a retired `entwurf/claude-sonnet-5`
|
|
74
|
+
* would open on `cortex-claude-sonnet-5` — another vendor and billing rail — with a receipt that
|
|
75
|
+
* still names the requested id. Asked here, at the one root both rails share, so neither rail can
|
|
76
|
+
* forget it. It reads the model the way the rails will (trimmed), and only a model that already
|
|
77
|
+
* passes their grammar: a malformed one keeps its own `model-invalid` answer from the rail.
|
|
78
|
+
*/
|
|
79
|
+
export function isUncuratedEntwurfPiModel(backend: string, model: string): boolean {
|
|
80
|
+
if (backend !== "pi") return false;
|
|
81
|
+
const trimmed = model.trim();
|
|
82
|
+
if (!isSafeFreshCallModel(trimmed)) return false;
|
|
83
|
+
const slash = trimmed.indexOf("/");
|
|
84
|
+
if (slash < 0 || trimmed.slice(0, slash) !== PROVIDER_ID) return false;
|
|
85
|
+
return !isCuratedAcpModelId(trimmed.slice(slash + 1));
|
|
86
|
+
}
|
|
87
|
+
|
|
70
88
|
/**
|
|
71
89
|
* Open a sibling on whichever rail this process is actually standing in.
|
|
72
90
|
*
|
|
@@ -88,6 +106,12 @@ export async function dispatchFreshCall(
|
|
|
88
106
|
spawn?: SpawnFn,
|
|
89
107
|
nonce: string = mintNonce(),
|
|
90
108
|
): Promise<DispatchedFreshCall> {
|
|
109
|
+
// Before either rail is asked anything: both would open a pane on the wrong row.
|
|
110
|
+
if (isUncuratedEntwurfPiModel(request.backend, request.model)) {
|
|
111
|
+
return selectFreshCallRail(env) === "herdr"
|
|
112
|
+
? { rail: "herdr", result: { ok: false, reason: ACP_MODEL_NOT_CURATED } }
|
|
113
|
+
: { rail: "tmux", result: { ok: false, reason: ACP_MODEL_NOT_CURATED } };
|
|
114
|
+
}
|
|
91
115
|
if (selectFreshCallRail(env) === "herdr") {
|
|
92
116
|
const context = resolveHerdrContext(env);
|
|
93
117
|
if (!context.ok) {
|
|
@@ -119,6 +119,7 @@ export type HerdrFreshCallRejectReason =
|
|
|
119
119
|
// The caller-facing input contract, shared with the tmux rail so a mistyped model or an
|
|
120
120
|
// oversized task is answered with the SAME word on either placement owner.
|
|
121
121
|
| FreshCallInputRejectReason
|
|
122
|
+
| "acp-model-not-curated"
|
|
122
123
|
| "herdr-context-missing"
|
|
123
124
|
| "herdr-caller-pane-missing"
|
|
124
125
|
/** herdr would not tell us where the CALLER is sitting. Three separate names because the
|
|
@@ -1102,6 +1103,10 @@ const HERDR_REJECT_HINT: Record<HerdrFreshCallRejectReason | HerdrLaunchFailureR
|
|
|
1102
1103
|
"this surface has no record-backed identity, so a sibling would have nowhere to call home",
|
|
1103
1104
|
"model-empty": "pass the model you want the sibling opened with — it is never inherited from this process",
|
|
1104
1105
|
"model-invalid": "the model is not in the accepted grammar (no whitespace, no leading dash)",
|
|
1106
|
+
// #128: decided at the dispatch root (`fresh-call-dispatch.ts`), worded here because this rail's
|
|
1107
|
+
// import fence admits no ACP module. The curated list itself lives in `acp/model-ids.js`.
|
|
1108
|
+
"acp-model-not-curated":
|
|
1109
|
+
"an entwurf model must be one exact curated ACP id — Pi would otherwise pick another row by substring (a retired claude-sonnet-5 lands on cortex-claude-sonnet-5), so nothing was opened and nothing was aliased",
|
|
1105
1110
|
"task-empty": "a sibling opened with nothing to do is a window nobody asked for",
|
|
1106
1111
|
"task-too-long": `the task is over the ${TASK_MAX_CHARS}-character interface bound this verb shares with entwurf_v2`,
|
|
1107
1112
|
"herdr-context-missing":
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
* there is no "session created" field because nothing here creates one.
|
|
87
87
|
*/
|
|
88
88
|
|
|
89
|
+
import { ACP_MODEL_NOT_CURATED_HINT } from "./acp/model-ids.js";
|
|
89
90
|
import { callbackEnvAssignments } from "./callback-env.ts";
|
|
90
91
|
import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
|
|
91
92
|
import {
|
|
@@ -265,6 +266,7 @@ export type FreshCallRejectReason =
|
|
|
265
266
|
| CodexPreflightRejectReason
|
|
266
267
|
| CodexCallerPreflightRejectReason
|
|
267
268
|
| CodexCallerSeatRejectReason
|
|
269
|
+
| "acp-model-not-curated"
|
|
268
270
|
| "caller-identity-unavailable"
|
|
269
271
|
| "model-empty"
|
|
270
272
|
| "model-invalid"
|
|
@@ -678,6 +680,7 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
|
|
|
678
680
|
"no session with that exact name answers on this agent's tmux server (or that server stopped answering) — nothing was created, so open the session yourself and call again",
|
|
679
681
|
"model-empty": "model is empty after trimming; fresh calls require an explicit model",
|
|
680
682
|
"model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
|
|
683
|
+
"acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
|
|
681
684
|
"task-empty": "task is empty after trimming",
|
|
682
685
|
"task-too-long": `task exceeds ${TASK_MAX_CHARS} characters`,
|
|
683
686
|
"runtime-unresolved": "the backend's runtime is not installed on PATH",
|
|
@@ -20,12 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
import { existsSync } from "node:fs";
|
|
22
22
|
import path from "node:path";
|
|
23
|
+
import { AcpModelNotCuratedError, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
|
|
23
24
|
import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.ts";
|
|
24
25
|
import { readAddressableMetaIdentity } from "./meta-session.ts";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
|
-
*
|
|
28
|
-
* same-id resume at all.
|
|
28
|
+
* One of the TWO expected refusals on this leaf: the target is a citizen of a backend that has no
|
|
29
|
+
* same-id resume at all. The other is `AcpModelNotCuratedError` (#128, `acp/model-ids.js`).
|
|
29
30
|
*
|
|
30
31
|
* Everything else this function throws is a defect in the record, the transcript or the
|
|
31
32
|
* environment — a stale path, a foreign session file, an unrecorded model, an unresolvable
|
|
@@ -152,11 +153,18 @@ export function resolveResumeLaunchIdentity(gardenId: string): LaunchIdentity {
|
|
|
152
153
|
if (!identity.cwd) {
|
|
153
154
|
throw new Error(`resume-launch-identity: ${gardenId} header has no cwd (the cold-resume authority, #9).`);
|
|
154
155
|
}
|
|
156
|
+
// #128: the pair checked is the one the launch will EMIT, not the recorded provider alone —
|
|
157
|
+
// `getEntwurfExplicitExtensions` also routes a bare Claude model id to `entwurf`. Under that
|
|
158
|
+
// provider Pi resolves `--model` by substring, so a retired `claude-sonnet-5` would reopen on
|
|
159
|
+
// `cortex-claude-sonnet-5`. The record and transcript stay as they are; the resume is refused.
|
|
160
|
+
const provider = explicitExtensions.provider ?? identity.provider;
|
|
161
|
+
const model = explicitExtensions.modelOverride ?? resumeModel;
|
|
162
|
+
if (provider === PROVIDER_ID && !isCuratedAcpModelId(model)) throw new AcpModelNotCuratedError(model);
|
|
155
163
|
return {
|
|
156
164
|
sessionFile,
|
|
157
165
|
cwd: identity.cwd,
|
|
158
166
|
explicitExtensionArgs: explicitExtensions.args,
|
|
159
|
-
provider
|
|
160
|
-
model
|
|
167
|
+
provider,
|
|
168
|
+
model,
|
|
161
169
|
};
|
|
162
170
|
}
|
|
@@ -58,6 +58,24 @@
|
|
|
58
58
|
* vanished signal, a failed ring, a rejected admission or close retires the doorbell — watch
|
|
59
59
|
* closed, our own marker removed, the reason kept by name — and nothing retries it.
|
|
60
60
|
*
|
|
61
|
+
* WITHDRAW (#140). Each notice is a native `followUp`, placed one per run; a read drains EVERY
|
|
62
|
+
* unread body. So after a read the notices still queued for that mail would each cost a turn and an
|
|
63
|
+
* empty read. The contact keeps the native `Submission` handle of each notice it admitted, with the
|
|
64
|
+
* `.msg` names that notice announced, only while that mail is unread. After every
|
|
65
|
+
* `entwurf_inbox_read` attempt — whatever its garden id or outcome — it lists its OWN mailbox once
|
|
66
|
+
* and withdraws, through the native `Submission.abort` and the read's own invocation Context, each
|
|
67
|
+
* notice none of whose names is still `.msg.delivered`. That per-notice file predicate is the only
|
|
68
|
+
* authority: no garden-id guard, no requestId lookup, no queue of our own, no rewritten text. A placed
|
|
69
|
+
* or settled notice answers so and is let go; a withdraw that fails is let go too, named in one
|
|
70
|
+
* stderr line and on the receiver report, never retried and never retiring the doorbell; a mailbox
|
|
71
|
+
* that cannot be listed proves nothing absent, so every notice is kept and that is named the same
|
|
72
|
+
* way. The read's own result or error is returned unchanged — no diagnostic joins it. Ring passes
|
|
73
|
+
* and reads share one gate: a read waits for an admission in flight (else its drain could beat the
|
|
74
|
+
* handle it must withdraw), and an arrival during a read is rung once the read lets go. The count
|
|
75
|
+
* in a notice is its admission-time snapshot. Not covered: notices admitted before a restart (no
|
|
76
|
+
* handle), mail drained by another process (until this contact's next read), and operator abort,
|
|
77
|
+
* which natively withdraws queued notices while their mail stays unread.
|
|
78
|
+
*
|
|
61
79
|
* VISIBLE IDENTITY (a static projection). `withGardenIdentity` hands the native TUI a view whose
|
|
62
80
|
* every conversation label carries this citizen's garden id, so the footer the TUI keeps on screen
|
|
63
81
|
* names the citizen; the bootstrap composes it only after birth succeeded. That is evidence of what
|
|
@@ -79,7 +97,8 @@
|
|
|
79
97
|
*
|
|
80
98
|
* NOT HERE: crash-safe receive. `entwurf_inbox_read` archives a body before the durable tool
|
|
81
99
|
* 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.
|
|
100
|
+
* call as interrupted and no doorbell rings again. A drain that fails part-way has archived the
|
|
101
|
+
* bodies before it (the bridge returns its error), and those too never reach the model. No resume.
|
|
83
102
|
*/
|
|
84
103
|
|
|
85
104
|
import * as fs from "node:fs";
|
|
@@ -530,9 +549,14 @@ export interface DurableDoorbellDraft {
|
|
|
530
549
|
readonly requestId: string;
|
|
531
550
|
}
|
|
532
551
|
|
|
533
|
-
/**
|
|
552
|
+
/** What the native `Submission.abort` answers: only a still-queued input is withdrawn. */
|
|
553
|
+
export type DurableWithdrawal = "aborted" | "already_placed" | "settled";
|
|
554
|
+
|
|
555
|
+
/** The part of the native `Submission` handle a doorbell keeps; admission is not completion. */
|
|
534
556
|
export interface DurableAdmittedSubmission {
|
|
535
557
|
readonly id: unknown;
|
|
558
|
+
/** Native `Submission.abort(context)`, handed the read's own invocation Context unchanged. */
|
|
559
|
+
abort(context: DurableCallContext | undefined): Promise<DurableWithdrawal>;
|
|
536
560
|
}
|
|
537
561
|
|
|
538
562
|
/** The overlay's `OpenDurableResult.submitToRoot`. */
|
|
@@ -562,21 +586,40 @@ export interface PiDurableDoorbellAdmission {
|
|
|
562
586
|
unread: number;
|
|
563
587
|
}
|
|
564
588
|
|
|
589
|
+
/**
|
|
590
|
+
* The LATEST notice withdrawal that failed. `inspection-failed`: the own mailbox could not be listed,
|
|
591
|
+
* so no notice was judged and every one is kept. `abort-failed`: the native withdraw of the notice
|
|
592
|
+
* named by `requestId` rejected; that notice is let go, not retried.
|
|
593
|
+
*/
|
|
594
|
+
export interface PiDurableWithdrawalFailure {
|
|
595
|
+
reason: "inspection-failed" | "abort-failed";
|
|
596
|
+
requestId?: string;
|
|
597
|
+
detail: string;
|
|
598
|
+
}
|
|
599
|
+
|
|
565
600
|
/**
|
|
566
601
|
* The receiver's diagnostic. `admissions` is the LATEST successful admission only — an empty array
|
|
567
602
|
* before the first ring, then exactly one entry, replaced by each later success; a retirement keeps
|
|
568
603
|
* the last one. It is a diagnostic, not a history: it never grows with the host's lifetime, and its
|
|
569
604
|
* length is no count of how often the root was asked (the root's own submissions are that record).
|
|
605
|
+
* `withdrawalFailure`, present only once a withdrawal failed, is likewise the latest one only.
|
|
570
606
|
*/
|
|
571
607
|
export type PiDurableReceiverReport =
|
|
572
608
|
| { status: "off" }
|
|
573
|
-
| {
|
|
609
|
+
| {
|
|
610
|
+
status: "armed";
|
|
611
|
+
gardenId: string;
|
|
612
|
+
markerPath: string;
|
|
613
|
+
admissions: PiDurableDoorbellAdmission[];
|
|
614
|
+
withdrawalFailure?: PiDurableWithdrawalFailure;
|
|
615
|
+
}
|
|
574
616
|
| {
|
|
575
617
|
status: "retired";
|
|
576
618
|
gardenId: string;
|
|
577
619
|
reason: PiDurableReceiverRetirement;
|
|
578
620
|
detail: string;
|
|
579
621
|
admissions: PiDurableDoorbellAdmission[];
|
|
622
|
+
withdrawalFailure?: PiDurableWithdrawalFailure;
|
|
580
623
|
};
|
|
581
624
|
|
|
582
625
|
export interface PiDurableAttachment {
|
|
@@ -641,6 +684,13 @@ interface ArmedDoorbell {
|
|
|
641
684
|
submitToRoot: DurableSubmitToRoot;
|
|
642
685
|
}
|
|
643
686
|
|
|
687
|
+
/** One admitted notice: its native handle and the `.msg` names it announced. */
|
|
688
|
+
interface HeldNotice {
|
|
689
|
+
readonly requestId: string;
|
|
690
|
+
readonly fresh: readonly string[];
|
|
691
|
+
readonly submission: DurableAdmittedSubmission;
|
|
692
|
+
}
|
|
693
|
+
|
|
644
694
|
const TOOL_DESCRIPTIONS: Record<PiDurableToolName, string> = {
|
|
645
695
|
entwurf_self:
|
|
646
696
|
"Return this durable host's garden identity envelope (sessionId = garden id, agentId, cwd, replyable, rail) " +
|
|
@@ -730,7 +780,8 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
730
780
|
throw new Error(`${name} arguments must be an object`);
|
|
731
781
|
}
|
|
732
782
|
const client = await waitReady(context?.abortSignal);
|
|
733
|
-
const
|
|
783
|
+
const call = () => client.callTool(name, (args ?? {}) as Record<string, unknown>, context?.abortSignal);
|
|
784
|
+
const result = name === "entwurf_inbox_read" ? await readInbox(call, context) : await call();
|
|
734
785
|
if (result.isError) throw new Error(result.text || `${name} failed`);
|
|
735
786
|
return { content: [{ type: "text", text: result.text }] };
|
|
736
787
|
},
|
|
@@ -745,8 +796,34 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
745
796
|
let doorbell: ArmedDoorbell | null = null;
|
|
746
797
|
let report: PiDurableReceiverReport = { status: "off" };
|
|
747
798
|
const admissions: PiDurableDoorbellAdmission[] = [];
|
|
748
|
-
|
|
799
|
+
/** The armed mailbox, kept past a retirement so notices admitted before it can still be judged. */
|
|
800
|
+
let inbox: string | null = null;
|
|
801
|
+
/** Every admitted notice still owed a withdraw judgement, oldest first. */
|
|
802
|
+
const notices: HeldNotice[] = [];
|
|
803
|
+
let withdrawalFailure: PiDurableWithdrawalFailure | undefined;
|
|
804
|
+
// One gate for ring passes and inbox reads; `pending` is a signal that landed while it was held.
|
|
805
|
+
let gated = false;
|
|
749
806
|
let pending = false;
|
|
807
|
+
const gateWaiters: (() => void)[] = [];
|
|
808
|
+
|
|
809
|
+
const enterGate = (): Promise<void> => {
|
|
810
|
+
if (!gated) {
|
|
811
|
+
gated = true;
|
|
812
|
+
return Promise.resolve();
|
|
813
|
+
}
|
|
814
|
+
return new Promise<void>((resolve) => gateWaiters.push(resolve));
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
/** Hand the gate to the next waiting read, else open it — and ring once for a signal that landed meanwhile. */
|
|
818
|
+
const leaveGate = (): void => {
|
|
819
|
+
const next = gateWaiters.shift();
|
|
820
|
+
if (next !== undefined) {
|
|
821
|
+
next();
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
gated = false;
|
|
825
|
+
if (pending) void ring("signal");
|
|
826
|
+
};
|
|
750
827
|
|
|
751
828
|
/**
|
|
752
829
|
* Give the doorbell back: close the watch and remove the marker, in one step, because a
|
|
@@ -791,19 +868,19 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
791
868
|
/**
|
|
792
869
|
* One contract with the other self-fetch doorbells: a fresh `*.msg` is the trigger, it is
|
|
793
870
|
* 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.
|
|
871
|
+
* `.msg.delivered`. Events that land while an admission or an inbox read holds the gate coalesce
|
|
872
|
+
* into one more pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg`
|
|
873
|
+
* rings — the startup pass included — so a `.msg.delivered` left undrained by an earlier host is
|
|
874
|
+
* not announced again on its own; it shows only in the count of the next fresh arrival.
|
|
798
875
|
*/
|
|
799
876
|
const ring = async (why: PiDurableDoorbellAdmission["why"]): Promise<void> => {
|
|
800
877
|
const armed = doorbell;
|
|
801
878
|
if (armed === null) return;
|
|
802
|
-
if (
|
|
879
|
+
if (gated) {
|
|
803
880
|
pending = true;
|
|
804
881
|
return;
|
|
805
882
|
}
|
|
806
|
-
|
|
883
|
+
gated = true;
|
|
807
884
|
try {
|
|
808
885
|
do {
|
|
809
886
|
pending = false;
|
|
@@ -836,11 +913,71 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
836
913
|
}
|
|
837
914
|
// Latest only: the diagnostic names the LAST success and never grows with the host's lifetime.
|
|
838
915
|
admissions.splice(0, admissions.length, { why, requestId, submissionId: submission.id, fresh, unread });
|
|
916
|
+
notices.push({ requestId, fresh, submission });
|
|
839
917
|
} while (pending && doorbell === armed);
|
|
840
918
|
} catch (error) {
|
|
841
919
|
retire("doorbell-failed", describeError(error));
|
|
842
920
|
} finally {
|
|
843
|
-
|
|
921
|
+
leaveGate();
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* One named stderr line per failed withdrawal — stderr ALWAYS, as `entwurf-control` does, since
|
|
927
|
+
* nothing in the native app reads the receiver report — and the report keeps the latest one.
|
|
928
|
+
*/
|
|
929
|
+
const noteWithdrawalFailure = (failure: PiDurableWithdrawalFailure): void => {
|
|
930
|
+
withdrawalFailure = failure;
|
|
931
|
+
const subject = failure.requestId === undefined ? "" : ` ${failure.requestId}`;
|
|
932
|
+
const detail = failure.detail.replace(/\s+/g, " ");
|
|
933
|
+
process.stderr.write(`[entwurf pi-durable] notice withdrawal ${failure.reason}${subject}: ${detail}\n`);
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Withdraw every held notice none of whose announced `.msg` names is still `.msg.delivered` in the
|
|
938
|
+
* armed mailbox, listed once. A mailbox that cannot be listed proves nothing absent: every notice
|
|
939
|
+
* is kept and that is named. A judged notice is let go whatever the native withdraw answers — or
|
|
940
|
+
* when it rejects, which is named and never retried — so no handle outlives its mail by more
|
|
941
|
+
* than one read. Never throws: the read's own answer is what the caller gets.
|
|
942
|
+
*/
|
|
943
|
+
const withdrawDrained = async (context: DurableCallContext | undefined): Promise<void> => {
|
|
944
|
+
if (inbox === null || notices.length === 0) return;
|
|
945
|
+
let unread: Set<string>;
|
|
946
|
+
try {
|
|
947
|
+
unread = new Set(fs.readdirSync(inbox).filter((name) => name.endsWith(".msg.delivered")));
|
|
948
|
+
} catch (error) {
|
|
949
|
+
noteWithdrawalFailure({ reason: "inspection-failed", detail: `${inbox}: ${describeError(error)}` });
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
for (const notice of [...notices]) {
|
|
953
|
+
if (notice.fresh.some((name) => unread.has(`${name}.delivered`))) continue;
|
|
954
|
+
notices.splice(notices.indexOf(notice), 1);
|
|
955
|
+
try {
|
|
956
|
+
await notice.submission.abort(context);
|
|
957
|
+
} catch (error) {
|
|
958
|
+
noteWithdrawalFailure({ reason: "abort-failed", requestId: notice.requestId, detail: describeError(error) });
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* An inbox read under the doorbell's gate, then the withdrawal its drain made due — after a
|
|
965
|
+
* failed read too, whose partial drain may already have archived mail. The bridge's own answer,
|
|
966
|
+
* result or rejection, is returned unchanged.
|
|
967
|
+
*/
|
|
968
|
+
const readInbox = async (
|
|
969
|
+
call: () => Promise<BridgeCallResult>,
|
|
970
|
+
context: DurableCallContext | undefined,
|
|
971
|
+
): Promise<BridgeCallResult> => {
|
|
972
|
+
await enterGate();
|
|
973
|
+
try {
|
|
974
|
+
return await call();
|
|
975
|
+
} finally {
|
|
976
|
+
try {
|
|
977
|
+
await withdrawDrained(context);
|
|
978
|
+
} finally {
|
|
979
|
+
leaveGate();
|
|
980
|
+
}
|
|
844
981
|
}
|
|
845
982
|
};
|
|
846
983
|
|
|
@@ -893,6 +1030,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
893
1030
|
receiversDir: roots.receiversDir,
|
|
894
1031
|
});
|
|
895
1032
|
doorbell = { gardenId, ownerPid, mailbox, signal, markerPath, watcher, submitToRoot };
|
|
1033
|
+
inbox = mailbox;
|
|
896
1034
|
report = { status: "armed", gardenId, markerPath, admissions };
|
|
897
1035
|
return markerPath;
|
|
898
1036
|
} catch (error) {
|
|
@@ -1011,13 +1149,15 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
1011
1149
|
settleFailed(describeError(error));
|
|
1012
1150
|
},
|
|
1013
1151
|
receiver() {
|
|
1014
|
-
return report;
|
|
1152
|
+
return withdrawalFailure === undefined || report.status === "off" ? report : { ...report, withdrawalFailure };
|
|
1015
1153
|
},
|
|
1016
1154
|
async close() {
|
|
1017
1155
|
const previous = state;
|
|
1018
1156
|
settleFailed("the contact closed before attach completed");
|
|
1019
1157
|
state = { kind: "closed" };
|
|
1020
1158
|
const client = previous.kind === "ready" ? previous.client : undefined;
|
|
1159
|
+
// Queued notices stay native (a continued session places them); this contact judges no more.
|
|
1160
|
+
notices.length = 0;
|
|
1021
1161
|
await closeAll(disarmOnClose, async () => {
|
|
1022
1162
|
await client?.close();
|
|
1023
1163
|
});
|