@junghanacs/entwurf 0.13.1 → 0.14.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.
Files changed (158) hide show
  1. package/AGENTS.md +59 -15
  2. package/BASELINE.md +3 -3
  3. package/CHANGELOG.md +74 -0
  4. package/CONTRIBUTING.md +13 -9
  5. package/DELIVERY.md +7 -6
  6. package/README.md +37 -28
  7. package/VERIFY.md +22 -14
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +9 -4
  12. package/docs/external-mcp-host.md +4 -5
  13. package/docs/setup-clean-host.md +8 -7
  14. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +155 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +300 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +141 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  38. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  39. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  40. package/mcp/entwurf-bridge/src/index.ts +173 -28
  41. package/mcp/entwurf-bridge/start.sh +2 -2
  42. package/mcp/entwurf-bridge/test.sh +23 -9
  43. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  44. package/package.json +22 -11
  45. package/pi-extensions/entwurf-control.ts +227 -40
  46. package/pi-extensions/lib/acp/backend.ts +71 -12
  47. package/pi-extensions/lib/acp/overlay.ts +8 -6
  48. package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
  49. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  50. package/pi-extensions/lib/entwurf-core.ts +15 -15
  51. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  52. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  53. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  54. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  55. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  56. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  57. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  58. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  59. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  60. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  61. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  62. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  63. package/pi-extensions/lib/meta-session.ts +93 -5
  64. package/pi-extensions/lib/mux-fresh-call.ts +381 -0
  65. package/pi-extensions/lib/mux-launch.ts +267 -0
  66. package/pi-extensions/lib/mux-placement.ts +387 -0
  67. package/pi-extensions/lib/mux-resume-call.ts +189 -0
  68. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  69. package/pi-extensions/lib/session-id.js +8 -5
  70. package/pi-extensions/lib/socket-discovery.ts +3 -3
  71. package/run.sh +471 -201
  72. package/scripts/agy-bridge-config.py +5 -1
  73. package/scripts/check-acp-backend-preflight.ts +1 -1
  74. package/scripts/check-acp-overlay.ts +13 -3
  75. package/scripts/check-acp-stream-hooks.ts +504 -0
  76. package/scripts/check-elapsed.sh +25 -0
  77. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  78. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  79. package/scripts/check-entwurf-control-rpc.ts +4 -3
  80. package/scripts/check-entwurf-resume-args.ts +72 -70
  81. package/scripts/check-entwurf-session-identity.ts +14 -10
  82. package/scripts/check-entwurf-v2-contract.ts +34 -59
  83. package/scripts/check-entwurf-v2-decider.ts +17 -177
  84. package/scripts/check-entwurf-v2-lock.ts +5 -2
  85. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  86. package/scripts/check-entwurf-v2-production.ts +2 -91
  87. package/scripts/check-entwurf-v2-release.ts +10 -105
  88. package/scripts/check-entwurf-v2-runner.ts +4 -85
  89. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  90. package/scripts/check-entwurf-v2-send.ts +0 -28
  91. package/scripts/check-entwurf-v2-surface.ts +157 -128
  92. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  93. package/scripts/check-fresh-cut-gate.sh +1 -1
  94. package/scripts/check-gate-qualification.ts +99 -7
  95. package/scripts/check-install-container.sh +10 -2
  96. package/scripts/check-install-surface.ts +1 -1
  97. package/scripts/check-keyset-overlap.py +1 -1
  98. package/scripts/check-meta-facts.ts +249 -0
  99. package/scripts/check-meta-identity-consumers.ts +1 -1
  100. package/scripts/check-meta-session.ts +169 -0
  101. package/scripts/check-mux-launch-tmux.ts +316 -0
  102. package/scripts/check-mux-launch.ts +288 -0
  103. package/scripts/check-mux-launcher-fence.ts +264 -0
  104. package/scripts/check-mux-parent-artifact.ts +195 -0
  105. package/scripts/check-mux-placement-tmux.ts +322 -0
  106. package/scripts/check-mux-placement.ts +323 -0
  107. package/scripts/check-mux-resume-call.ts +283 -0
  108. package/scripts/check-probe-cli-shim.ts +25 -22
  109. package/scripts/check-probe-ordering.ts +84 -76
  110. package/scripts/check-release-gate-outcomes.ts +127 -7
  111. package/scripts/check-resume-launch-identity.ts +244 -0
  112. package/scripts/check-socket-discovery.ts +1 -1
  113. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  114. package/scripts/inventory-verification-surface.ts +349 -0
  115. package/scripts/lib/claude-launcher-fence.ts +322 -0
  116. package/scripts/lib/mutation-qualify.ts +109 -3
  117. package/scripts/meta-bridge-doctor.sh +6 -8
  118. package/scripts/meta-bridge-state.py +75 -1
  119. package/scripts/meta-facts.ts +60 -0
  120. package/scripts/mutants/acp-overlay.json +17 -0
  121. package/scripts/mutants/acp-stream-hooks.json +158 -0
  122. package/scripts/mutants/bridge-boot-resume.json +45 -0
  123. package/scripts/mutants/meta-facts.json +50 -0
  124. package/scripts/mutants/meta-identity.json +36 -0
  125. package/scripts/mutants/meta-retire.json +47 -0
  126. package/scripts/mutants/mux-boundary.json +196 -0
  127. package/scripts/mutants/mux-fresh-call.json +229 -0
  128. package/scripts/mutants/mux-launcher-fence.json +123 -0
  129. package/scripts/mutants/mux-parent-artifact.json +39 -0
  130. package/scripts/mutants/mux-resume-call.json +148 -0
  131. package/scripts/mutants/probe-ordering.json +0 -1037
  132. package/scripts/mutants/release-gate.json +35 -0
  133. package/scripts/mutants/resume-args.json +76 -0
  134. package/scripts/mutants/resume-launch-identity.json +96 -0
  135. package/scripts/mutants/v2-surface.json +58 -18
  136. package/scripts/mutants/v2-visible-resume.json +215 -0
  137. package/scripts/new-session-id.ts +9 -4
  138. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  139. package/scripts/smoke-agy-native-push-live.ts +6 -17
  140. package/scripts/smoke-entwurf-chain-live.ts +11 -3
  141. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  142. package/scripts/smoke-meta-honesty.sh +1 -1
  143. package/scripts/smoke-meta-install-state.sh +169 -3
  144. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  145. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  146. package/scripts/smoke-pi-attach.ts +1 -1
  147. package/scripts/smoke-user-scope-citizen.sh +1 -1
  148. package/scripts/tsconfig.json +1 -0
  149. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  150. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  151. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  152. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  153. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  154. package/scripts/check-acp-sdk-surface.ts +0 -275
  155. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  156. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  157. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  158. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
@@ -25,7 +25,7 @@
25
25
  * WHY THE PI SIDE IS THE SEAM AND NOT A REAL `pi` PROCESS. The record + address half
26
26
  * is driven through `birthPiCitizen` — the exact function `entwurf-control.ts`'s
27
27
  * session_start calls — so this gate stays deterministic (no model, no network, no
28
- * cost) and belongs in `pnpm check`. That a REAL `pi --entwurf-control` process
28
+ * cost) and belongs in `pnpm run check:full`. That a REAL `pi --entwurf-control` process
29
29
  * executes that seam is a different axis, owned by the LIVE gate
30
30
  * (`smoke-resident-garden-guard`, inverted in this same cut). Two axes, neither
31
31
  * pretending to be the other.
@@ -8,7 +8,7 @@
8
8
  # cwd because entwurf was absent from ~/.pi/agent/settings.json packages[]).
9
9
  # statusline `?` had a tripwire; THIS omission had none and hid until GLG hit it
10
10
  # in another repo. This gate is that missing tripwire: if the registration ever
11
- # regresses, `pnpm check` goes red.
11
+ # regresses, `pnpm run check:full` goes red.
12
12
  #
13
13
  # Cells 11–13 close the second omission (#53 B): every case here drove a fake
14
14
  # settings file with ABSOLUTE entries, so the portable, settings-relative form this
@@ -26,6 +26,7 @@
26
26
  "exclude": ["../node_modules", "../mcp", "../plugins"],
27
27
  "include": [
28
28
  "./**/*.ts",
29
+ "../test/**/*.ts",
29
30
  "../pi-extensions/lib/**/*.ts",
30
31
  "../pi-extensions/model-lock.ts",
31
32
  "../pi-extensions/meta-bridge-hook.ts",
@@ -1,160 +0,0 @@
1
- /**
2
- * entwurf-preflight — 0.11 Stage 0 (2): the SINGLE trust/launch decision owner.
3
- *
4
- * The controlled-launch surface, the global `project_trust` handler, and any
5
- * MCP fact tool ALL consume this module's outcome — nobody else re-derives a
6
- * prefix, re-reads `trust.json`, or re-probes trust inputs. pi's raw trust
7
- * semantics are followed by importing pi's PUBLIC root exports directly (frozen
8
- * decision 9, 재구현 금지): `ProjectTrustStore` (the canonical `trust.json`
9
- * reader, which itself canonicalizes the cwd and takes a `proper-lockfile` on
10
- * every read) and `hasTrustRequiringProjectResources` (the trust-input probe).
11
- * We never copy pi's trust detail — if pi changes it, this import tracks it.
12
- *
13
- * The returned `PreflightOutcome` is deliberately RICH, not just {kind,reason}:
14
- * a fact tool must explain *why* a cwd is approved and *what* it may load
15
- * without re-running the probe, and an error/handler must name the matched root
16
- * or the trust-store value. Thin outcomes would push callers to recompute, which
17
- * is exactly the re-derivation this module exists to prevent.
18
- *
19
- * trust ≠ discovery: this decision touches the store for a SINGLE launch-time
20
- * cwd only. `peers`/`who-can` discovery does not call here (frozen decision 4).
21
- *
22
- * Precedence (frozen decision 8) — saved distrust is stronger than a prefix
23
- * allow; a prefix only promotes the UNDECIDED (null) case; no-trust-inputs is
24
- * trusted but needs no launch arg; everything else is fail-fast:
25
- *
26
- * saved === false → deny (explicit distrust; store wins)
27
- * saved === true → approve (saved trust → internal --approve)
28
- * null + prefix match → approve (operator prefix promotes null→yes)
29
- * null + no trust inputs → trusted-no-arg (no trust-gated input — pi 0.79.x
30
- * excludes AGENTS.md/CLAUDE.md, so
31
- * context files may still be loaded)
32
- * else (null + inputs) → fail-fast (unknown/untrusted controlled launch)
33
- *
34
- * Injection (frozen decision 4): `agentDir` defaults to `getAgentDir()` but is
35
- * overridable so tests point `ProjectTrustStore` at a temp dir (or set
36
- * `PI_CODING_AGENT_DIR`, same isolation as 0.10.0) and never read or dirty the
37
- * operator's real `~/.pi/agent/trust.json`. `prefixRoots` is an OPERATOR-policy
38
- * input with NO package default (frozen decision 7): a public package must not
39
- * hardcode a broad auto-approve, so an empty roots list means "no prefix
40
- * promotion" — the caller injects the operator's roots (e.g. `~/repos/gh`).
41
- */
42
- import { realpathSync } from "node:fs";
43
- import { homedir } from "node:os";
44
- import { isAbsolute, join, resolve, sep } from "node:path";
45
- import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore, } from "@earendil-works/pi-coding-agent";
46
- /**
47
- * Render the human-facing reason a controlled launch was refused (N3b). This is
48
- * a PURE formatter over a deny outcome — it does NOT touch a launcher, a socket,
49
- * or pi; wiring it into the controlled-launch surface is bucket B (step 5), not
50
- * here. The launcher/handler/error layers all call this so the refusal text is
51
- * identical everywhere and always sourced from F5a evidence.
52
- *
53
- * The inherited-false branch is the one that matters: an operator distrust on an
54
- * ANCESTOR (e.g. `~/repos/gh`) silently denies a child cwd, and an agent CANNOT
55
- * lift it — that is an intended security property (N3a: a controlled launch
56
- * short-circuits on `trustOverride` and never reaches the human-only active
57
- * prompt). So the message must (1) name the inherited source (`inheritedFrom`)
58
- * and (2) give the only real remedy: open an interactive pi AT the cwd and
59
- * approve, which writes a direct child trust that beats the inherited decision
60
- * (the "escape direction" proven in check-pi-preflight #13b).
61
- */
62
- export function formatPreflightDenial(outcome) {
63
- const cwd = outcome.canonicalCwd;
64
- const openHere = `open an interactive pi at ${cwd} and approve when prompted`;
65
- if (outcome.reason === "saved-false") {
66
- if (outcome.trustStoreInherited && outcome.trustStoreEntryPath !== undefined) {
67
- return (`Controlled launch refused: ${cwd} is distrusted by inheritance from ${outcome.trustStoreEntryPath} ` +
68
- `(an ancestor carries a saved "no"). An agent cannot self-promote trust — this is an intended ` +
69
- `security property. To trust THIS cwd only, ${openHere}; that writes a direct decision for ${cwd} ` +
70
- `which overrides the inherited one.`);
71
- }
72
- return `Controlled launch refused: ${cwd} is explicitly distrusted (a saved "no" on this directory). To change it, ${openHere}.`;
73
- }
74
- // fail-fast: undecided + trust inputs + no operator prefix root.
75
- return (`Controlled launch refused: ${cwd} is untrusted — it has trust inputs but no saved decision and no ` +
76
- `operator prefix root. Refusing a silent degraded launch. Either add ${cwd} under an operator prefix ` +
77
- `root, or ${openHere}.`);
78
- }
79
- /**
80
- * Normalize a path the way pi resolves one before the trust store sees it:
81
- * expand a leading `~`, make it absolute (`path.resolve`), then `realpathSync`;
82
- * on a resolve failure fall back to the RESOLVED absolute path (not the raw
83
- * input), so a not-yet-existing root still compares on an absolute basis.
84
- */
85
- function normalizePath(p) {
86
- let expanded = p;
87
- if (p === "~") {
88
- expanded = homedir();
89
- }
90
- else if (p.startsWith("~/")) {
91
- expanded = join(homedir(), p.slice(2));
92
- }
93
- const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
94
- try {
95
- return realpathSync(abs);
96
- }
97
- catch {
98
- return abs;
99
- }
100
- }
101
- /**
102
- * Return the canonical operator root that contains `canonicalCwd`, by canonical
103
- * path + separator boundary (frozen decision 7). `/org` matches `/org/a` but NOT
104
- * `/org2` — never a bare `startsWith`. Roots are normalized the same as the cwd.
105
- */
106
- function matchedPrefixRoot(canonicalCwd, roots) {
107
- for (const root of roots) {
108
- const r = normalizePath(root);
109
- if (canonicalCwd === r || canonicalCwd.startsWith(r + sep)) {
110
- return r;
111
- }
112
- }
113
- return undefined;
114
- }
115
- /** Decide trust for a single controlled-launch cwd. See module header. */
116
- export function preflight(input) {
117
- const agentDir = input.agentDir ?? getAgentDir();
118
- const prefixRoots = input.prefixRoots ?? [];
119
- const canonicalCwd = normalizePath(input.cwd);
120
- const store = new ProjectTrustStore(agentDir);
121
- // getEntry, not get: get() throws away which path decided. getEntry returns
122
- // `{ path, decision } | null` — the nearest ancestor (or the cwd itself)
123
- // carrying an explicit decision. We recover the same decision value AND the
124
- // deciding path, so the fact/handler/error layers can name an inherited
125
- // source without re-walking the store. entry.path is pi-canonical, the same
126
- // realpath axis as `canonicalCwd`, so an entry on the cwd ITSELF compares
127
- // equal (= direct) and an ancestor compares unequal (= inherited).
128
- const entry = store.getEntry(input.cwd);
129
- const trustStoreDecision = entry?.decision ?? null;
130
- const trustStoreInherited = entry !== null && entry.path !== canonicalCwd;
131
- // Computed unconditionally: a fact tool must report what a prefix-approved
132
- // cwd could load, so the probe runs even when a prefix already decides.
133
- const hasTrustInputs = hasTrustRequiringProjectResources(input.cwd);
134
- const matched = matchedPrefixRoot(canonicalCwd, prefixRoots);
135
- const evidence = {
136
- launchArgs: [],
137
- trustStoreDecision,
138
- trustStoreInherited,
139
- hasTrustInputs,
140
- canonicalCwd,
141
- ...(entry !== null ? { trustStoreEntryPath: entry.path } : {}),
142
- ...(matched !== undefined ? { matchedPrefixRoot: matched } : {}),
143
- };
144
- // Explicit distrust wins over everything, including a prefix match.
145
- if (trustStoreDecision === false) {
146
- return { ...evidence, kind: "deny", reason: "saved-false" };
147
- }
148
- if (trustStoreDecision === true) {
149
- return { ...evidence, kind: "approve", reason: "saved-true", launchArgs: ["--approve"] };
150
- }
151
- // trustStoreDecision === null (undecided): a prefix promotes it; otherwise the
152
- // absence of trust inputs makes it trusted-but-no-arg; otherwise fail-fast.
153
- if (matched !== undefined) {
154
- return { ...evidence, kind: "approve", reason: "prefix-match", launchArgs: ["--approve"] };
155
- }
156
- if (!hasTrustInputs) {
157
- return { ...evidence, kind: "trusted-no-arg", reason: "no-trust-inputs" };
158
- }
159
- return { ...evidence, kind: "deny", reason: "fail-fast" };
160
- }
@@ -1,273 +0,0 @@
1
- /**
2
- * entwurf-v2-spawn-production — the PRODUCTION `SpawnBgResumeDeps` factory for the 5c-3a
3
- * spawn-bg watcher (0.11 Stage 0 step 5c-3c). It wires the six injected IO seams the pure
4
- * watcher (`executeSpawnBgResume`) declares onto the real world: spawn a resume child as a
5
- * resident citizen, observe its control socket by CONNECTABILITY (not mere existence),
6
- * watch its exit, time the observe window, and kill on timeout. The watcher's release
7
- * policy is untouched — this module only supplies the IO.
8
- *
9
- * Every seam stays injectable (defaults = the real fns) so a DETERMINISTIC gate drives
10
- * spawnChild's argv, the exit/timeout/kill wiring, and the socket-alive verdict WITHOUT a
11
- * real pi spawn or socket — the heavy live path (a real `pi --entwurf-control` resident
12
- * child + a real unix-socket connect) is proven once by a separate opt-in smoke
13
- * (`smoke-entwurf-v2-spawn-live`), kept OUT of `pnpm check` so the every-commit loop stays
14
- * fast and deterministic (D5: chain = deterministic gate; live smoke = phase gate before 5d).
15
- *
16
- * The factory does NOT capture `plan` or `lock` (D3): `SpawnBgResumeDeps.spawnChild(plan)`
17
- * already takes the plan, and the lock is the watcher's authority — it flows to
18
- * `deps.releaseLock` from the watcher, never from here. A captured plan/lock would be a
19
- * second authority that could drift from the one the watcher holds.
20
- *
21
- * socket-alive = CONNECTABLE, never file-exists (GPT 5c-3c, Q5): inspect the EXACT
22
- * `plan.expectedSocketPath` (via the path-addressed `inspectControlSocketPath` — no gid
23
- * re-derivation), map to liveness through the shared `mapInspectionToLiveness`, then apply
24
- * `socketWatchVerdict`: a connectable socket is alive; a forged address (symlink / not a
25
- * socket) is rejected IMMEDIATELY (time does not heal a forged path → the watcher's backstop
26
- * kills and fail-closes to a retained lock); a dead/indeterminate socket keeps waiting (the
27
- * dormant citizen's stale socket file, or a stall, is "not up yet", not a failure).
28
- */
29
- import { spawn } from "node:child_process";
30
- import { existsSync } from "node:fs";
31
- import * as fs from "node:fs/promises";
32
- import { getEntwurfExplicitExtensions, mirrorChildStderr, readSessionIdentity } from "./entwurf-core.js";
33
- import { buildResumePiArgs } from "./entwurf-resume-args.js";
34
- import { releaseLock } from "./entwurf-v2-lock.js";
35
- import { readAddressableMetaIdentity } from "./meta-session.js";
36
- import { inspectControlSocketPath, mapInspectionToLiveness } from "./socket-discovery.js";
37
- import { probeSocketLiveness } from "./socket-probe.js";
38
- /**
39
- * Translate a mapped socket observation into the watcher's poll decision.
40
- * - forged — an address-conflict (symlink / not-a-socket). NOT a transient liveness
41
- * state: time cannot turn a forged path into a live socket, so the poll must REJECT
42
- * immediately (→ watcher backstop → kill → retained), never keep waiting.
43
- * - alive — a connectable socket. Resolve: the resumed citizen is up.
44
- * - wait — dead (the dormant citizen's stale/absent socket — "not up yet") or
45
- * indeterminate (a stall). Keep polling until the socket connects, or the observe
46
- * timeout / abort ends the wait.
47
- */
48
- export function socketWatchVerdict(mapped) {
49
- if ("addressConflict" in mapped)
50
- return "forged";
51
- return mapped.liveness === "alive" ? "alive" : "wait";
52
- }
53
- /** Narrow a watcher-supplied SpawnedChild back to the production handle. A child that is
54
- * NOT one we spawned (no `proc` / `exitPromise`) is a mis-wire — fail loud, never no-op. */
55
- function requireProductionChild(child) {
56
- const c = child;
57
- if (!c.proc || !c.exitPromise) {
58
- throw new Error("entwurf-v2-spawn-production: child handle has no proc/exitPromise (mis-wired SpawnBgResumeDeps).");
59
- }
60
- return c;
61
- }
62
- /**
63
- * Resolve launch identity for a resume. The TARGET is now resolved through the
64
- * meta-record (#50 C2): `gardenId → record.transcriptPath`. It used to be a global
65
- * header scan for a JSONL whose header id equalled the garden id — which only worked
66
- * while entwurf forced pi's session id to BE the garden id. With the record minting the
67
- * address, that scan cannot find anything (a citizen's header carries pi's own uuid), so
68
- * keeping it would not have been a "smaller change", it would have been a broken one.
69
- *
70
- * The record is also the AUTHORIZATION now (#50 C3). The old gates — `requireEntwurf`
71
- * (an `entwurf` tag in the session NAME, planted by a name mirror that no longer
72
- * exists) and the sessionId-bound resume-marker env — are deleted. Record-backed pi
73
- * citizens are all siblings (LOCKED PROTOCOL 6), so "this garden id names a pi citizen
74
- * with a recorded transcript" is the whole test, PLUS one integrity check: the resumed
75
- * file's header id must equal `record.nativeSessionId` (pi owns the transcript, the
76
- * record remembers whose it is — a mismatch means the transcriptPath is stale or
77
- * foreign, and resuming it would put a turn into a different being's session).
78
- *
79
- * That check is per-record, and per-record is not enough: the v2 lock domain is keyed on
80
- * GARDEN ID, so two records sharing one `nativeSessionId` would each pass their own
81
- * integrity check and resume the SAME transcript concurrently under two different locks.
82
- * Hence {@link readAddressableMetaIdentity} rather than the plain targeted read (#52) —
83
- * a resume is exactly the moment a record stops being data and becomes an address.
84
- *
85
- * Everything else is unchanged authority: readSessionIdentity (first model_change) for
86
- * provider/model/cwd, getEntwurfExplicitExtensions for bridge re-injection (#29 fail-fast).
87
- * Throws on anything that makes a resume impossible; each throw becomes the watcher's
88
- * `spawn-start-failed` (no child to watch → release), never a silent no-op.
89
- */
90
- export function resolveResumeLaunchIdentity(plan) {
91
- const record = readAddressableMetaIdentity(plan.sessionId);
92
- if (record.backend !== "pi") {
93
- throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} is a ${record.backend} citizen — ` +
94
- `spawn-bg resume is a host-adapter capability, and its domain currently contains backend pi only. ` +
95
- `This is a relaunch capability, not the control-socket rail and not citizen rank.`);
96
- }
97
- const sessionFile = record.transcriptPath;
98
- if (!sessionFile) {
99
- throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} has no recorded transcriptPath — ` +
100
- `the citizen never wrote a session file (no turn yet), so there is nothing to resume.`);
101
- }
102
- // A recorded path is only a resume target while the file is actually on disk.
103
- // Without this check a missing transcript falls through readSessionIdentity's
104
- // ENOENT swallow and surfaces as "no recorded model" — the wrong cause (F7):
105
- // the transcript was deleted, or the record carries a phantom path minted
106
- // before birth guarded on file existence.
107
- if (!existsSync(sessionFile)) {
108
- throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} recorded transcriptPath "${sessionFile}" ` +
109
- `does not exist on disk — the transcript was deleted, or the record carries a phantom ` +
110
- `path from a pre-guard birth; nothing to resume.`);
111
- }
112
- const identity = readSessionIdentity(sessionFile);
113
- const resumeModel = identity?.modelId ?? null;
114
- if (!identity || !resumeModel) {
115
- throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} has no recorded model — cannot resume.`);
116
- }
117
- if (identity.sessionId !== record.nativeSessionId) {
118
- throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} transcript header id "${identity.sessionId ?? "(none)"}" ` +
119
- `does not match the record's nativeSessionId "${record.nativeSessionId}" — the recorded transcriptPath ` +
120
- `is stale or points at a foreign session file; refusing to resume another being's transcript.`);
121
- }
122
- const explicitExtensions = getEntwurfExplicitExtensions(resumeModel, false, identity.provider);
123
- if (explicitExtensions.unresolvedAcpIntent) {
124
- throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} recorded provider=entwurf but the bridge ` +
125
- `extension could not be resolved — refusing to resume with an unknown provider (#29).`);
126
- }
127
- if (!identity.cwd) {
128
- throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} header has no cwd (the cold-resume authority, #9).`);
129
- }
130
- return {
131
- sessionFile,
132
- cwd: identity.cwd,
133
- explicitExtensionArgs: explicitExtensions.args,
134
- provider: explicitExtensions.provider ?? identity.provider,
135
- model: explicitExtensions.modelOverride ?? resumeModel,
136
- };
137
- }
138
- const DEFAULT_POLL_INTERVAL_MS = 150;
139
- const DEFAULT_KILL_GRACE_MS = 5_000;
140
- /** The default spawnChild: a detached, unref'd `pi` resident child with stderr mirrored —
141
- * the same launch posture as the legacy worker, minus `--no-extensions` (the argv comes
142
- * from buildResumePiArgs v2-control). Detached so the resumed citizen survives this parent. */
143
- function defaultSpawnChild(cmd, args, cwd) {
144
- const proc = spawn(cmd, [...args], {
145
- cwd,
146
- shell: false,
147
- detached: true,
148
- stdio: ["ignore", "ignore", "pipe"],
149
- });
150
- proc.unref();
151
- mirrorChildStderr(proc);
152
- return proc;
153
- }
154
- /**
155
- * Build the production `SpawnBgResumeDeps` the 5c-3a watcher consumes. The factory captures
156
- * NO plan and NO lock (D3) — both flow through the watcher. Pass `opts` to inject fakes for
157
- * the deterministic gate; the defaults are the real IO.
158
- */
159
- export function makeProductionSpawnBgResumeDeps(opts = {}) {
160
- const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
161
- const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
162
- const resolveIdentity = opts.resolveIdentity ?? resolveResumeLaunchIdentity;
163
- const spawnChildFn = opts.spawnChild ?? defaultSpawnChild;
164
- const lstatFn = opts.lstatFn ?? ((p) => fs.lstat(p));
165
- const probeFn = opts.probeFn ?? ((socketPath) => probeSocketLiveness(socketPath, { timeoutMs: opts.probeTimeoutMs }));
166
- const releaseFn = opts.releaseFn ?? ((lock) => void releaseLock(lock));
167
- const setTimeoutFn = opts.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
168
- const clearTimeoutFn = opts.clearTimeoutFn ?? ((t) => clearTimeout(t));
169
- // An abortable delay: resolves after `ms`, or rejects on abort (clearing the timer). Used
170
- // by both awaitTimeout (the observe/grace deadlines) and the socket poll's inter-poll sleep.
171
- const abortableDelay = (ms, signal) => new Promise((resolve, reject) => {
172
- if (signal.aborted) {
173
- reject(new Error("aborted"));
174
- return;
175
- }
176
- const timer = setTimeoutFn(() => {
177
- signal.removeEventListener("abort", onAbort);
178
- resolve();
179
- }, ms);
180
- const onAbort = () => {
181
- clearTimeoutFn(timer);
182
- reject(new Error("aborted"));
183
- };
184
- signal.addEventListener("abort", onAbort, { once: true });
185
- });
186
- return {
187
- killGraceMs,
188
- spawnChild: async (plan) => {
189
- const identity = resolveIdentity(plan);
190
- const args = buildResumePiArgs({
191
- variant: "v2-control",
192
- sessionFile: identity.sessionFile,
193
- explicitExtensionArgs: identity.explicitExtensionArgs,
194
- provider: identity.provider,
195
- model: identity.model,
196
- prompt: plan.prompt,
197
- launchArgs: plan.launchArgs,
198
- });
199
- const proc = spawnChildFn("pi", args, identity.cwd);
200
- // B2: capture exit EAGERLY — the instant the proc exists, before we even await the
201
- // spawn — so a fast exit cannot slip through the gap before awaitChildExit. Resolve-
202
- // only (a signal kill reports null); it never rejects, so racing it against abort is safe.
203
- const exitPromise = new Promise((resolve) => {
204
- proc.on("exit", (code) => resolve(typeof code === "number" ? code : null));
205
- });
206
- // B1: a started process is NOT guaranteed by `spawn()` returning — a spawn-time
207
- // failure (ENOENT pi / exec error) arrives as an `error` event, not a sync throw. Wait
208
- // for the `spawn` event to confirm a real start; an `error` before it rejects, which the
209
- // watcher turns into spawn-start-failed (release, nothing to watch) — NOT a silent
210
- // spawn-started that later stalls into a wrongful retained lock.
211
- await new Promise((resolve, reject) => {
212
- const onSpawn = () => {
213
- cleanup();
214
- resolve();
215
- };
216
- const onError = (err) => {
217
- cleanup();
218
- reject(err instanceof Error ? err : new Error(`spawn failed: ${String(err)}`));
219
- };
220
- const cleanup = () => {
221
- proc.removeListener("spawn", onSpawn);
222
- proc.removeListener("error", onError);
223
- };
224
- proc.on("spawn", onSpawn);
225
- proc.on("error", onError);
226
- });
227
- const child = { pid: proc.pid, proc, exitPromise };
228
- return child;
229
- },
230
- awaitSocketAlive: async (socketPath, signal) => {
231
- // Poll the EXACT path (no gid re-derivation). Connectable → resolve; forged →
232
- // reject NOW; dead/indeterminate → wait one interval and re-poll, until abort.
233
- while (!signal.aborted) {
234
- const inspection = await inspectControlSocketPath(socketPath, lstatFn);
235
- const mapped = await mapInspectionToLiveness(inspection, probeFn);
236
- const verdict = socketWatchVerdict(mapped);
237
- if (verdict === "alive")
238
- return;
239
- if (verdict === "forged") {
240
- throw new Error(`entwurf-v2-spawn-production: forged control-socket address at ${socketPath} (never connected).`);
241
- }
242
- // wait — sleep one interval, honoring abort (rejects → loop exits below).
243
- await abortableDelay(pollIntervalMs, signal);
244
- }
245
- throw new Error("entwurf-v2-spawn-production: awaitSocketAlive aborted before the socket became alive.");
246
- },
247
- awaitChildExit: (child, signal) =>
248
- // Race the EAGER exitPromise (installed at spawn, so a fast exit is already captured —
249
- // B2) against abort. exitPromise is resolve-only, so the loser never surfaces as an
250
- // unhandled rejection; only the abort branch rejects.
251
- new Promise((resolve, reject) => {
252
- const { exitPromise } = requireProductionChild(child);
253
- if (signal.aborted) {
254
- reject(new Error("aborted"));
255
- return;
256
- }
257
- const onAbort = () => reject(new Error("aborted"));
258
- signal.addEventListener("abort", onAbort, { once: true });
259
- exitPromise.then((code) => {
260
- signal.removeEventListener("abort", onAbort);
261
- resolve(code);
262
- });
263
- }),
264
- awaitTimeout: (ms, signal) => abortableDelay(ms, signal),
265
- killChild: (child) => {
266
- // SIGTERM only this slice — the watcher's killGrace then waits for the resulting
267
- // child-exited; if none arrives it returns lock-retained (a SIGKILL escalator is a
268
- // separate policy + smoke). Best-effort: the watcher catches a throw here.
269
- requireProductionChild(child).proc.kill("SIGTERM");
270
- },
271
- releaseLock: (lock) => releaseFn(lock),
272
- };
273
- }