phoebe-agent 0.0.0 → 0.1.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 (75) hide show
  1. package/README.md +77 -8
  2. package/bootstrap/bin.mjs +42 -0
  3. package/bootstrap/boot.ts +431 -0
  4. package/bootstrap/cli.ts +29 -0
  5. package/bootstrap/crash-loop.ts +391 -0
  6. package/bootstrap/define-config.ts +20 -0
  7. package/bootstrap/engine-source.ts +83 -0
  8. package/bootstrap/github-engine.ts +169 -0
  9. package/bootstrap/index.mjs +9 -0
  10. package/bootstrap/index.ts +24 -0
  11. package/bootstrap/materialize.mjs +53 -0
  12. package/bootstrap/reconcile.ts +314 -0
  13. package/bootstrap/spawn-engine.mjs +78 -0
  14. package/package.json +26 -24
  15. package/prompts/checks-prompt.md +21 -6
  16. package/prompts/conflict-prompt.md +12 -1
  17. package/prompts/research-prompt.md +61 -0
  18. package/src/agent-env.ts +32 -0
  19. package/src/branded.ts +19 -0
  20. package/src/cli.ts +187 -0
  21. package/src/config-schema.ts +278 -0
  22. package/src/drain.ts +74 -0
  23. package/src/execution-gate.ts +27 -0
  24. package/src/git-model.ts +145 -0
  25. package/src/init.ts +277 -0
  26. package/src/load-config.ts +168 -0
  27. package/src/main.ts +1636 -0
  28. package/src/orchestrator.ts +953 -0
  29. package/src/prompt.ts +98 -0
  30. package/src/providers/providers.ts +222 -0
  31. package/src/providers/run-agent.ts +90 -0
  32. package/src/providers/types.ts +26 -0
  33. package/src/resolved-config.ts +55 -0
  34. package/templates/.env.example +17 -5
  35. package/templates/container/Dockerfile +128 -19
  36. package/templates/container/compose.local.yml +37 -0
  37. package/templates/container/compose.yml +43 -13
  38. package/templates/phoebe.config.ts +30 -6
  39. package/dist/phoebe.config.d.ts +0 -2
  40. package/dist/phoebe.config.js +0 -43
  41. package/dist/src/agent-env.d.ts +0 -6
  42. package/dist/src/agent-env.js +0 -24
  43. package/dist/src/cli.d.ts +0 -25
  44. package/dist/src/cli.js +0 -161
  45. package/dist/src/config-schema.d.ts +0 -163
  46. package/dist/src/config-schema.js +0 -140
  47. package/dist/src/execution-gate.d.ts +0 -9
  48. package/dist/src/execution-gate.js +0 -17
  49. package/dist/src/git-model.d.ts +0 -30
  50. package/dist/src/git-model.js +0 -71
  51. package/dist/src/index.d.ts +0 -2
  52. package/dist/src/index.js +0 -12
  53. package/dist/src/init.d.ts +0 -82
  54. package/dist/src/init.js +0 -207
  55. package/dist/src/load-config.d.ts +0 -88
  56. package/dist/src/load-config.js +0 -153
  57. package/dist/src/main.d.ts +0 -7
  58. package/dist/src/main.js +0 -1180
  59. package/dist/src/orchestrator.d.ts +0 -275
  60. package/dist/src/orchestrator.js +0 -579
  61. package/dist/src/prompt.d.ts +0 -13
  62. package/dist/src/prompt.js +0 -55
  63. package/dist/src/providers/providers.d.ts +0 -3
  64. package/dist/src/providers/providers.js +0 -210
  65. package/dist/src/providers/run-agent.d.ts +0 -34
  66. package/dist/src/providers/run-agent.js +0 -57
  67. package/dist/src/providers/types.d.ts +0 -27
  68. package/dist/src/providers/types.js +0 -6
  69. package/dist/src/resolved-config.d.ts +0 -8
  70. package/dist/src/resolved-config.js +0 -49
  71. package/dist/src/supervisor-decision.d.ts +0 -70
  72. package/dist/src/supervisor-decision.js +0 -94
  73. package/templates/container/compose.daemon.yml +0 -16
  74. package/templates/container/supervisor.sh +0 -50
  75. /package/prompts/{prompt.md → issues-prompt.md} +0 -0
package/README.md CHANGED
@@ -24,17 +24,86 @@ pinned CLI — you never vendor the engine source into your repo, only a small
24
24
  config file, your prompt overrides, and the container files `phoebe init`
25
25
  scaffolds for you.
26
26
 
27
+ ## Quickstart
28
+
29
+ From the root of the repo you want Phoebe to work:
30
+
31
+ ```bash
32
+ npx --yes phoebe-agent init # scaffold config, prompts, .env.example, container/
33
+ ```
34
+
35
+ Then edit the five required fields in `phoebe.config.ts`, pin the engine with
36
+ `engine: { source: "github", ref: "v0.1.0" }`, and copy `.env.example` to `.env`
37
+ and fill in your `GH_TOKEN` and provider key. The scaffolded `.env` lives at the
38
+ repo root while the compose files live in `container/`, so pass
39
+ `--env-file ../.env` when you run Compose from there:
40
+
41
+ ```bash
42
+ cd container
43
+ docker compose --env-file ../.env build
44
+ docker compose --env-file ../.env run --rm phoebe --dry-run --run-once # preview one unit
45
+ docker compose --env-file ../.env up -d # start the daemon
46
+ ```
47
+
48
+ The container's main process is `phoebe boot`: it checks the engine out at the
49
+ ref your config names, runs it, and keeps supervising it. Upgrading is an edit to
50
+ `engine.ref` — no rebuild, no restart, provided you edit the file in place (the
51
+ config is bind-mounted as a single file, so a save-by-rename needs a
52
+ `docker compose up -d --force-recreate` to be seen).
53
+
54
+ The full, execute-top-to-bottom version — prerequisites, secrets, verification —
55
+ is [`docs/ai-install.md`](docs/ai-install.md).
56
+
57
+ ## Configuration at a glance
58
+
59
+ Only five fields are required; everything else falls back to a shipped default.
60
+
61
+ ```ts
62
+ import type { PhoebeUserConfig } from "phoebe-agent";
63
+
64
+ const config: PhoebeUserConfig = {
65
+ repoSlug: "your-org/your-repo",
66
+ repoUrl: "https://github.com/your-org/your-repo.git",
67
+ installCommand: "npm ci",
68
+ checkCommand: "npm run check",
69
+ testCommand: "npm test",
70
+ engine: { source: "github", ref: "v0.1.0" },
71
+ };
72
+
73
+ export default config;
74
+ ```
75
+
76
+ | Field | Default | What it controls |
77
+ | ----------------- | ---------------------------------------- | ----------------------------------------------- |
78
+ | `repoSlug` | _required_ | GitHub `owner/repo` for every `gh` call. |
79
+ | `repoUrl` | _required_ | Clone URL for the container's private clone. |
80
+ | `installCommand` | _required_ | Dependency install run in each worktree. |
81
+ | `checkCommand` | _required_ | Lint/type gate. |
82
+ | `testCommand` | _required_ | Test gate. |
83
+ | `defaultBranch` | `main` | Branch PRs target and worktrees base off. |
84
+ | `branchPrefix` | `phoebe/` | Prefix for agent branches. |
85
+ | `readyLabel` | `ready-for-agent` | Label marking issues Phoebe may pick up. |
86
+ | `researchLabel` | `wayfinder:research` | Label marking wayfinder research tickets. |
87
+ | `prOptOutLabel` | `ready-for-human` | Label that hands a PR back to a human. |
88
+ | `workOrder` | conflicts→checks→reviews→issues→research | Order the work kinds are tried. |
89
+ | `defaultProvider` | `cursor` | Agent CLI to drive (`cursor`/`claude`/`codex`). |
90
+
91
+ See [`docs/configuration.md`](docs/configuration.md) for the complete field
92
+ reference and the `PHOEBE_*` environment overlay.
93
+
27
94
  ## Documentation
28
95
 
29
- Docs live under [`docs/`](docs/) and grow as the engine lands:
96
+ Docs live under [`docs/`](docs/):
30
97
 
31
- - `docs/architecture.md` — topology, worktree isolation, supervisor self-update.
32
- - `docs/configuration.md` — full config-field reference.
33
- - `docs/work-kinds.md` — issues / conflicts / checks / reviews mechanics.
34
- - `docs/operating.md` — controlling Phoebe as a human (labels, drafts, watermarks).
35
- - `docs/upgrading.md` — the init / pin / upgrade contract.
36
- - `docs/ai-install.md` — a deterministic, agent-followable install runbook.
37
- - `docs/releasing.md` — the Changesets + npm trusted-publishing release flow.
98
+ - [`docs/architecture.md`](docs/architecture.md) — topology, worktree isolation, engine updates and crash-loop fallback, named volumes.
99
+ - [`docs/configuration.md`](docs/configuration.md) — full config-field reference and env overlay.
100
+ - [`docs/work-kinds.md`](docs/work-kinds.md) — issues / conflicts / checks / reviews / research mechanics, PR-scan scope, poll loop.
101
+ - [`docs/operating.md`](docs/operating.md) — controlling Phoebe as a human (labels, drafts, watermarks).
102
+ - [`docs/upgrading.md`](docs/upgrading.md) — the init / pin / upgrade contract.
103
+ - [`docs/ai-install.md`](docs/ai-install.md) — a deterministic, agent-followable install runbook.
104
+ - [`docs/releasing.md`](docs/releasing.md) — the Changesets + npm trusted-publishing release flow.
105
+ - [`docs/phoebe-core-onboarding.md`](docs/phoebe-core-onboarding.md) — worked onboarding for `JesusFilm/core` (Nx + pnpm, no vp).
106
+ - [`docs/trust.md`](docs/trust.md) — contributor trust list (`vouch`) for this repo, and how it relates to `ready-for-agent`. Governance for this repository, not a package feature.
38
107
 
39
108
  Agents landing in this repo should start at [`AGENTS.md`](AGENTS.md).
40
109
 
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Published `phoebe` / `phoebe-agent` bin — a dumb launcher, nothing more.
4
+ //
5
+ // It has to be plain JS: npm symlinks this file inside `node_modules`, and Node
6
+ // 24 refuses to type-strip `.ts` there. So it does the one thing that lets the
7
+ // real, TypeScript bootstrapper run: it copies the package out of node_modules
8
+ // (materialize.mjs) and execs the raw-`.ts` entry (bootstrap/cli.ts) with plain
9
+ // `node`, from outside node_modules where type-stripping is allowed. Every
10
+ // argument is forwarded untouched, so behavior is the bootstrapper's, not this
11
+ // shim's.
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { ensureEngine } from "./materialize.mjs";
18
+ import { spawnEngine } from "./spawn-engine.mjs";
19
+
20
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
21
+
22
+ function fail(message) {
23
+ console.error(`[phoebe] ${message}`);
24
+ process.exit(1);
25
+ }
26
+
27
+ let entry;
28
+ try {
29
+ const { version } = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
30
+ // Override the materialization root with PHOEBE_ENGINE_DIR (e.g. a persistent
31
+ // volume); default to a per-user temp dir, re-materialized cheaply if wiped.
32
+ const baseDir = process.env.PHOEBE_ENGINE_DIR ?? join(tmpdir(), "phoebe-agent");
33
+ entry = ensureEngine({ packageRoot, baseDir, version });
34
+ } catch (error) {
35
+ fail(error instanceof Error ? error.message : String(error));
36
+ }
37
+
38
+ // Exec the TypeScript bootstrapper, forwarding the stop signals a container
39
+ // stop delivers so a SIGTERM drain reaches the real process (the engine), not
40
+ // just this shim, and dying however the child dies. The plumbing
41
+ // lives in spawn-engine.mjs, shared with `phoebe boot`.
42
+ spawnEngine(entry, process.argv.slice(2), { onSpawnError: (error) => fail(error.message) });
@@ -0,0 +1,431 @@
1
+ // `phoebe boot` — the container's long-lived main process.
2
+ //
3
+ // The bootstrapper's job at boot is small: read the mounted consumer config,
4
+ // resolve where the engine source lives, and exec that engine as a long-running
5
+ // child (its normal persistent poll loop). Stop signals are forwarded to the
6
+ // child so a container `SIGTERM` reaches the engine and triggers its graceful
7
+ // drain (src/drain.ts); the child's exit is propagated so the container exits
8
+ // with the engine's status.
9
+ //
10
+ // Two engine sources are wired: `local` — a host→container mount at
11
+ // `/opt/phoebe-engine` (the dev-only `compose.local.yml` overlay, #40) — and
12
+ // `github` — a git checkout of the engine repo at a ref (github-engine.ts, #41).
13
+ //
14
+ // Boot then stays in charge for the life of the container: the reconcile watch
15
+ // (reconcile.ts, #42) polls the mounted config and the tracked ref, and when
16
+ // either moves it drains the engine, re-resolves the source, and relaunches —
17
+ // same container, no interrupted work unit. Following a branch also means
18
+ // eventually following it onto a commit that will not boot, so every launch
19
+ // passes through the crash-loop guard (crash-loop.ts, #43): a tip that dies fast
20
+ // enough times is quarantined and boot materializes the last commit that ran
21
+ // healthily instead. This module is the wiring; the loop lives in reconcile.ts,
22
+ // the fallback policy in crash-loop.ts, and everything impure is passed in from
23
+ // here.
24
+
25
+ import { execFileSync } from "node:child_process";
26
+ import { existsSync } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import { join } from "node:path";
29
+ import { installDrainSignal } from "../src/drain.ts";
30
+ import { defaultGit, type GitRunner } from "../src/git-model.ts";
31
+ import { loadUserConfig, resolveConfigPath } from "../src/load-config.ts";
32
+ import {
33
+ crashLoopStatePath,
34
+ createCrashGuard,
35
+ readStateDir,
36
+ DEFAULT_STATE_DIR,
37
+ type CrashGuard,
38
+ type CrashGuardEvent,
39
+ type RunOutcome,
40
+ } from "./crash-loop.ts";
41
+ import { readEngineSource, type ResolvedEngineSource } from "./engine-source.ts";
42
+ import { lsRemoteBranchSha, materializeGithubEngine } from "./github-engine.ts";
43
+ import {
44
+ configFingerprint,
45
+ superviseEngine,
46
+ CRASH_BACKOFF_MS,
47
+ DEFAULT_RECONCILE_INTERVAL_MS,
48
+ type EngineExit,
49
+ type EngineRun,
50
+ type LaunchedEngine,
51
+ type SupervisedChild,
52
+ } from "./reconcile.ts";
53
+ // Untyped plain-JS import (see spawn-engine.mjs / materialize.mjs for why the
54
+ // bootstrapper's child-process plumbing can't be TypeScript).
55
+ import { propagateExit, spawnEngine } from "./spawn-engine.mjs";
56
+
57
+ /** Where the local-engine compose overlay mounts the engine for `source: "local"`. */
58
+ export const LOCAL_ENGINE_DIR = "/opt/phoebe-engine";
59
+
60
+ /**
61
+ * Runs `gh` with the given argv. Injectable so boot's credential-helper setup is
62
+ * unit-tested without a real `gh` binary or a writable `~/.gitconfig`.
63
+ */
64
+ export type GhRunner = (args: readonly string[]) => void;
65
+
66
+ export const defaultGh: GhRunner = (args) => {
67
+ execFileSync("gh", args, { stdio: "inherit" });
68
+ };
69
+
70
+ /**
71
+ * Configure a global git credential helper from `GH_TOKEN` so every later git
72
+ * call against github.com authenticates — the engine's `ensureClone` /
73
+ * `fetchOrigin` / `pushBranch`, and the agent child's own `git push`/`fetch`.
74
+ *
75
+ * Uses `gh auth setup-git --hostname github.com`, which writes a
76
+ * `!gh auth git-credential` helper into `~/.gitconfig`. That helper reads
77
+ * `GH_TOKEN` live per call, so no secret is written to disk and token rotation
78
+ * keeps working. Only `github.com` is configured (Phoebe is github-only).
79
+ *
80
+ * Skipped when no token is present (public/anonymous path unchanged). A failed
81
+ * setup warns and continues — a missing helper is better diagnosed at the first
82
+ * private-repo clone than by aborting the container here.
83
+ */
84
+ export function setupGitCredentials(deps: {
85
+ token: string | undefined;
86
+ gh?: GhRunner;
87
+ warn?: (message: string) => void;
88
+ }): void {
89
+ if (!deps.token) return;
90
+ const gh = deps.gh ?? defaultGh;
91
+ const warn = deps.warn ?? ((message) => console.warn(message));
92
+ try {
93
+ gh(["auth", "setup-git", "--hostname", "github.com"]);
94
+ } catch (error) {
95
+ warn(
96
+ `[phoebe] boot: could not configure git credentials — ${describe(error)}. ` +
97
+ `Continuing without a credential helper.`,
98
+ );
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Resolve a `local` engine source to the mounted engine's `src/cli.ts`, failing
104
+ * loudly if it is absent — a missing/empty mount means a misconfigured
105
+ * container, not a fallback. Checking the entry file (not just the directory)
106
+ * catches a mounted-but-empty volume too. `github` is handled separately
107
+ * (materializeGithubEngine), so this only ever sees `local`.
108
+ *
109
+ * `exists`/`localEngineDir` are injectable so the decision is unit-tested
110
+ * without a real filesystem.
111
+ */
112
+ export function resolveEngineEntry(
113
+ _source: { source: "local" },
114
+ deps: { localEngineDir?: string; exists?: (path: string) => boolean } = {},
115
+ ): string {
116
+ const exists = deps.exists ?? existsSync;
117
+ const dir = deps.localEngineDir ?? LOCAL_ENGINE_DIR;
118
+ const entry = join(dir, "src", "cli.ts");
119
+ if (!exists(entry)) {
120
+ throw new Error(
121
+ `engine.source is "local" but no engine is mounted at ${dir} (missing ${entry}). ` +
122
+ `Mount the engine there (container/compose.local.yml) before \`phoebe boot\`.`,
123
+ );
124
+ }
125
+ return entry;
126
+ }
127
+
128
+ /**
129
+ * Base directory the github source clones the engine into. Reuses
130
+ * `PHOEBE_ENGINE_DIR` (the same knob bin.mjs materializes under); point it at a
131
+ * persistent volume so github clones survive restarts and later boots fetch
132
+ * instead of re-cloning. Defaults to a per-user temp dir for local dev.
133
+ */
134
+ function engineBaseDir(): string {
135
+ return process.env["PHOEBE_ENGINE_DIR"] ?? join(tmpdir(), "phoebe-agent");
136
+ }
137
+
138
+ /**
139
+ * How often the reconcile watch samples the config and the tracked ref.
140
+ * `PHOEBE_RECONCILE_INTERVAL_MS` tightens it for dogfooding (the default is a
141
+ * minute, which is a long time to wait when demonstrating a relaunch).
142
+ */
143
+ function reconcileIntervalMs(): number {
144
+ const raw = Number(process.env["PHOEBE_RECONCILE_INTERVAL_MS"]);
145
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_RECONCILE_INTERVAL_MS;
146
+ }
147
+
148
+ /**
149
+ * Load the mounted `phoebe.config.ts` as the arbitrary record the bootstrapper
150
+ * treats it as — it owns only two fields (`engine`, `paths.stateDir`), and the
151
+ * engine validates the rest once it is materialized and run. The fingerprint
152
+ * doubles as the ESM cache-bust key, so a re-read after an edit is genuinely a
153
+ * re-read.
154
+ */
155
+ async function loadMountedConfig(
156
+ configPath: string,
157
+ fingerprint: string | null,
158
+ ): Promise<Record<string, unknown>> {
159
+ const userConfig = await loadUserConfig(configPath, { reloadKey: fingerprint ?? undefined });
160
+ return userConfig as unknown as Record<string, unknown>;
161
+ }
162
+
163
+ /**
164
+ * Read the config and turn the engine source it names into something runnable —
165
+ * the whole of a (re)launch. Called once at boot and again for every reconcile,
166
+ * so an edited config is genuinely re-read (hence the fingerprint as the ESM
167
+ * cache-bust key) and a moved ref is genuinely re-fetched.
168
+ *
169
+ * The tracked ref's tip is materialized first even when a fallback is in force:
170
+ * the tip is what the guard's verdict is *about*, so resolving it is how boot
171
+ * notices both that the quarantine still applies and that the branch has moved
172
+ * past it. A fallback then checks out the last-good commit in the same clone.
173
+ */
174
+ async function launchTarget(configPath: string, guard: CrashGuard): Promise<LaunchedEngine> {
175
+ const fingerprint = configFingerprint(configPath);
176
+ const source = readEngineSource(await loadMountedConfig(configPath, fingerprint));
177
+ const token = process.env["GH_TOKEN"];
178
+ const sample = () => ({
179
+ config: configFingerprint(configPath),
180
+ remoteSha: watchedRefSha(source, token),
181
+ });
182
+
183
+ if (source.source === "local") {
184
+ const entry = resolveEngineEntry(source);
185
+ console.log(`[phoebe] boot: engine source "local" — exec ${entry} (long-running).`);
186
+ return { entry, sha: null, config: fingerprint, guarded: false, quarantinedSha: null, sample };
187
+ }
188
+
189
+ const guarded = isMovingBranch(source, token);
190
+ const baseDir = engineBaseDir();
191
+ let { entry, sha } = materializeGithubEngine(source, { baseDir, token });
192
+
193
+ let quarantinedSha: string | null = null;
194
+ const pin = guarded && sha !== null ? guard.fallbackFor(sha) : null;
195
+ if (pin !== null) {
196
+ quarantinedSha = sha;
197
+ ({ entry, sha } = materializeGithubEngine({ ...source, ref: pin }, { baseDir, token }));
198
+ }
199
+
200
+ const provenance =
201
+ quarantinedSha !== null
202
+ ? `${source.repo}@${source.ref} → last-good ${sha} (crash-loop fallback from ${quarantinedSha})`
203
+ : `${source.repo}@${source.ref}${sha ? ` (${sha})` : ""}`;
204
+ console.log(
205
+ `[phoebe] boot: engine source "github" ${provenance} — exec ${entry} (long-running).`,
206
+ );
207
+
208
+ return { entry, sha, config: fingerprint, guarded, quarantinedSha, sample };
209
+ }
210
+
211
+ /**
212
+ * Does the crash-loop guard apply to this source? Only a moving branch is
213
+ * guarded: a local mount has no commit to pin, and a pinned SHA or tag means the
214
+ * operator chose that exact commit — quietly serving a different one would be
215
+ * worse than crash-looping visibly. `lsRemoteBranchSha` answers precisely that
216
+ * question (it yields a tip only for a branch) and short-circuits a pinned SHA
217
+ * without touching the network.
218
+ *
219
+ * A remote that will not answer leaves the guard off for this launch rather than
220
+ * failing it: materializing is about to make the same call, and its error is the
221
+ * one worth surfacing.
222
+ */
223
+ export function isMovingBranch(
224
+ source: ResolvedEngineSource,
225
+ token: string | undefined,
226
+ git: GitRunner = defaultGit,
227
+ ): boolean {
228
+ if (source.source === "local") return false;
229
+ try {
230
+ return lsRemoteBranchSha(source, { token, git }) !== null;
231
+ } catch (error) {
232
+ console.warn(
233
+ `[phoebe] boot: could not check whether ${source.repo}@${source.ref} is a moving branch — ` +
234
+ `${describe(error)}. Crash-loop fallback is off for this launch.`,
235
+ );
236
+ return false;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * The ref half of a poll: where the tracked branch points now, or null when
242
+ * there is nothing to watch (a local mount, or a pinned SHA/tag — which the
243
+ * ref-watch leaves alone by design).
244
+ */
245
+ function watchedRefSha(source: ResolvedEngineSource, token: string | undefined): string | null {
246
+ if (source.source === "local") return null;
247
+ return lsRemoteBranchSha(source, { token });
248
+ }
249
+
250
+ /**
251
+ * Spawn the engine and expose it as the supervisor's child handle. Both a normal
252
+ * exit and a spawn failure settle `exited` — the failure as a non-zero exit — so
253
+ * the supervisor always sees the child resolve and decides what to do (a
254
+ * first-launch failure is fatal, a relaunch failure retries). Without the
255
+ * `onSpawnError` override, spawn-engine.mjs's default would `process.exit(1)`
256
+ * here, bypassing boot's drain-latch teardown and leaving `exited` pending.
257
+ */
258
+ function spawnSupervised(entry: string, argv: readonly string[]): SupervisedChild {
259
+ let settle!: (exit: EngineExit) => void;
260
+ const exited = new Promise<EngineExit>((resolve) => {
261
+ settle = resolve;
262
+ });
263
+ const child = spawnEngine(entry, argv, {
264
+ onExit: (code: number | null, signal: NodeJS.Signals | null) => settle({ code, signal }),
265
+ onSpawnError: (error: Error) => {
266
+ console.error(`[phoebe] boot: engine failed to spawn — ${error.message}`);
267
+ settle({ code: 1, signal: null });
268
+ },
269
+ });
270
+ return { kill: (signal) => child.kill(signal), exited };
271
+ }
272
+
273
+ /**
274
+ * The crash-loop guard for this container, rooted at the engine's state dir
275
+ * (`paths.stateDir`, a named volume). Resolved once from the config as it reads
276
+ * at boot: the record has to be found again after the restart a crash-looping
277
+ * engine causes, so where it lives must not drift with a mid-flight config edit.
278
+ * A config that will not load falls back to the shipped default here and fails
279
+ * properly on the first launch, where the error belongs.
280
+ */
281
+ async function createBootCrashGuard(configPath: string): Promise<CrashGuard> {
282
+ let stateDir = DEFAULT_STATE_DIR;
283
+ try {
284
+ stateDir = readStateDir(await loadMountedConfig(configPath, configFingerprint(configPath)));
285
+ } catch {
286
+ // launchTarget loads the same config a moment later and reports the failure.
287
+ }
288
+ return createCrashGuard({
289
+ statePath: crashLoopStatePath(stateDir),
290
+ onEvent: logCrashGuardEvent,
291
+ });
292
+ }
293
+
294
+ /**
295
+ * The guard's decisions, in an operator's terms. A container quietly serving
296
+ * older code than its config asks for is exactly the confusion these lines
297
+ * exist to prevent, so every fallback event names both commits.
298
+ */
299
+ function logCrashGuardEvent(event: CrashGuardEvent): void {
300
+ switch (event.kind) {
301
+ case "crash":
302
+ console.error(
303
+ `[phoebe] boot: engine ${event.sha} exited ${event.exitCode} after ` +
304
+ `${Math.round(event.elapsedMs / 1000)}s — fast crash ${event.failureCount}/${event.threshold}.`,
305
+ );
306
+ return;
307
+ case "last-good":
308
+ console.log(
309
+ `[phoebe] boot: engine ${event.sha} ran healthily — recorded as the crash-loop fallback target.`,
310
+ );
311
+ return;
312
+ case "fallback":
313
+ console.error(
314
+ `[phoebe] boot: engine ${event.quarantinedSha} crash-looped ${event.failureCount}× — ` +
315
+ `falling back to last-good ${event.lastGoodSha}, and staying there until the tracked ` +
316
+ `ref moves past the bad commit.`,
317
+ );
318
+ return;
319
+ case "fallback-crashed":
320
+ console.error(
321
+ `[phoebe] boot: the last-good engine ${event.sha} crashed too ` +
322
+ `(exit ${event.exitCode} after ${Math.round(event.elapsedMs / 1000)}s) — ` +
323
+ `${event.quarantinedSha} stays quarantined and the container will exit.`,
324
+ );
325
+ return;
326
+ case "recovered":
327
+ console.log(
328
+ `[phoebe] boot: tracked ref advanced to ${event.sha}, past quarantined ` +
329
+ `${event.quarantinedSha} — crash-loop fallback lifted.`,
330
+ );
331
+ return;
332
+ case "persist-failed":
333
+ console.warn(
334
+ `[phoebe] boot: could not write crash-loop state to ${event.path} — ` +
335
+ `${describe(event.error)}. The fallback will not survive a container restart.`,
336
+ );
337
+ return;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * A finished run as the crash-loop guard sees it, or null when there is no
343
+ * commit to say anything about (a local mount). Note this is *not* gated on
344
+ * `guarded`: what a pinned launch proved is still worth remembering — it only
345
+ * must not cause a fallback — and recording it means an operator who later moves
346
+ * that deployment onto a branch already has a target to fall back to.
347
+ */
348
+ function runOutcome(run: EngineRun): RunOutcome | null {
349
+ if (run.engine.sha === null) return null;
350
+ return {
351
+ sha: run.engine.sha,
352
+ exitCode: run.exit.code,
353
+ elapsedMs: run.elapsedMs,
354
+ requestedStop: run.requestedStop,
355
+ };
356
+ }
357
+
358
+ /**
359
+ * `phoebe boot` entry. Loads the mounted config, resolves the engine source to a
360
+ * runnable `src/cli.ts` — a local mount or a github checkout — execs the engine
361
+ * as a long-lived child, and supervises it: reconcile relaunches on a config or
362
+ * ref change, the crash-loop guard pins back to the last-good commit when the
363
+ * tracked ref will not boot, and a container stop drains it and exits with its
364
+ * status. Extra args after `boot` are forwarded to the engine (none ⇒ the
365
+ * persistent loop).
366
+ */
367
+ export async function runBoot(argv: readonly string[]): Promise<void> {
368
+ // Before any engine git call (ensureClone, fetch/push, agent child): one
369
+ // global github.com credential helper from GH_TOKEN. Survives reconcile
370
+ // relaunches via ~/.gitconfig + the agent-env HOME/GH_TOKEN allowlist.
371
+ setupGitCredentials({ token: process.env["GH_TOKEN"] });
372
+
373
+ const configPath = resolveConfigPath(undefined, process.cwd());
374
+ const guard = await createBootCrashGuard(configPath);
375
+ const intervalMs = reconcileIntervalMs();
376
+
377
+ // The container's stop request. A one-way latch, and the poll clock: a
378
+ // SIGTERM mid-poll wakes the watch immediately instead of sleeping out the
379
+ // interval. Holding these listeners also keeps boot alive across the moment
380
+ // between an engine exiting and its replacement spawning, where the child's
381
+ // own forwarders are not installed.
382
+ const stop = installDrainSignal(process, ["SIGTERM", "SIGINT"]);
383
+ let exit: EngineExit;
384
+ try {
385
+ exit = await superviseEngine({
386
+ launch: () => launchTarget(configPath, guard),
387
+ spawn: (entry) => spawnSupervised(entry, argv),
388
+ stop,
389
+ intervalMs,
390
+ onRunEnd: (run) => {
391
+ const outcome = runOutcome(run);
392
+ if (outcome !== null) guard.record(outcome);
393
+ },
394
+ onRunTick: ({ engine, elapsedMs }) => {
395
+ if (engine.sha !== null) guard.noteAlive(engine.sha, elapsedMs);
396
+ },
397
+ relaunchAfterExit: (run) => {
398
+ // Only a guarded launch retries: a pinned ref that crashes takes the
399
+ // container down, exactly as it did before there was a guard.
400
+ const outcome = run.engine.guarded ? runOutcome(run) : null;
401
+ if (outcome === null || !guard.shouldRetry(outcome)) return false;
402
+ console.log(
403
+ `[phoebe] boot: relaunching the engine in ${Math.round(CRASH_BACKOFF_MS / 1000)}s — ` +
404
+ `a last-good engine commit is available to fall back to.`,
405
+ );
406
+ return true;
407
+ },
408
+ onRelaunch: (reason) =>
409
+ console.log(
410
+ reason === "config"
411
+ ? "[phoebe] boot: mounted config changed — draining the engine (SIGTERM) and relaunching."
412
+ : "[phoebe] boot: tracked ref advanced — draining the engine (SIGTERM) and relaunching.",
413
+ ),
414
+ onLaunchError: (error) =>
415
+ console.error(
416
+ `[phoebe] boot: could not launch the engine — ${describe(error)}. Retrying next poll.`,
417
+ ),
418
+ onSampleError: (error) =>
419
+ console.warn(`[phoebe] boot: reconcile poll failed — ${describe(error)}. Ignoring.`),
420
+ });
421
+ } finally {
422
+ // Drop the listeners before propagating: re-raising the engine's killing
423
+ // signal must actually kill this process, and our own latch would swallow it.
424
+ stop.dispose();
425
+ }
426
+ propagateExit(exit.code, exit.signal);
427
+ }
428
+
429
+ function describe(error: unknown): string {
430
+ return error instanceof Error ? error.message : String(error);
431
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+
3
+ // `phoebe` bootstrapper entry — the real command surface, in TypeScript.
4
+ //
5
+ // This is NOT the file npm symlinks as the bin: Node 24 refuses to type-strip
6
+ // `.ts` under `node_modules`, so a tiny JS launcher (bootstrap/bin.mjs) is the
7
+ // bin instead. That launcher copies the package out of `node_modules` and execs
8
+ // THIS module with plain `node` — from outside `node_modules`, where raw `.ts`
9
+ // runs. So all bootstrapper logic lives here as type-checked TypeScript.
10
+ //
11
+ // `phoebe boot` (bootstrap/boot.ts) is the container's long-lived main process:
12
+ // it resolves the engine source (bootstrap/engine-source.ts) — a local mount or
13
+ // a github checkout (bootstrap/github-engine.ts) — execs the engine as a
14
+ // long-running child forwarding SIGTERM so the engine drains, and supervises it
15
+ // with the reconcile watch (bootstrap/reconcile.ts): a config or ref change
16
+ // drains and respawns in place. Every other invocation delegates to the engine
17
+ // CLI's `runCli` — scaffold via `init`, otherwise run the engine directly.
18
+
19
+ import { runCli } from "../src/cli.ts";
20
+ import { runBoot } from "./boot.ts";
21
+
22
+ const argv = process.argv.slice(2);
23
+ const command = argv[0] === "boot" ? runBoot(argv.slice(1)) : runCli();
24
+
25
+ command.catch((error: unknown) => {
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ console.error(`[phoebe] ${message}`);
28
+ process.exit(1);
29
+ });