phoebe-agent 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -8
- package/bootstrap/bin.mjs +42 -0
- package/bootstrap/boot.ts +383 -0
- package/bootstrap/cli.ts +29 -0
- package/bootstrap/crash-loop.ts +391 -0
- package/bootstrap/define-config.ts +20 -0
- package/bootstrap/engine-source.ts +83 -0
- package/bootstrap/github-engine.ts +169 -0
- package/bootstrap/index.mjs +9 -0
- package/bootstrap/index.ts +24 -0
- package/bootstrap/materialize.mjs +53 -0
- package/bootstrap/reconcile.ts +314 -0
- package/bootstrap/spawn-engine.mjs +78 -0
- package/package.json +26 -24
- package/prompts/checks-prompt.md +21 -6
- package/prompts/conflict-prompt.md +12 -1
- package/prompts/research-prompt.md +61 -0
- package/src/agent-env.ts +32 -0
- package/src/branded.ts +19 -0
- package/src/cli.ts +187 -0
- package/src/config-schema.ts +278 -0
- package/src/drain.ts +74 -0
- package/src/execution-gate.ts +27 -0
- package/src/git-model.ts +113 -0
- package/src/init.ts +277 -0
- package/src/load-config.ts +168 -0
- package/src/main.ts +1636 -0
- package/src/orchestrator.ts +953 -0
- package/src/prompt.ts +98 -0
- package/src/providers/providers.ts +222 -0
- package/src/providers/run-agent.ts +90 -0
- package/src/providers/types.ts +26 -0
- package/src/resolved-config.ts +55 -0
- package/templates/.env.example +11 -5
- package/templates/container/Dockerfile +41 -19
- package/templates/container/compose.local.yml +37 -0
- package/templates/container/compose.yml +34 -13
- package/templates/phoebe.config.ts +30 -6
- package/dist/phoebe.config.d.ts +0 -2
- package/dist/phoebe.config.js +0 -43
- package/dist/src/agent-env.d.ts +0 -6
- package/dist/src/agent-env.js +0 -24
- package/dist/src/cli.d.ts +0 -25
- package/dist/src/cli.js +0 -161
- package/dist/src/config-schema.d.ts +0 -163
- package/dist/src/config-schema.js +0 -140
- package/dist/src/execution-gate.d.ts +0 -9
- package/dist/src/execution-gate.js +0 -17
- package/dist/src/git-model.d.ts +0 -30
- package/dist/src/git-model.js +0 -71
- package/dist/src/index.d.ts +0 -2
- package/dist/src/index.js +0 -12
- package/dist/src/init.d.ts +0 -82
- package/dist/src/init.js +0 -207
- package/dist/src/load-config.d.ts +0 -88
- package/dist/src/load-config.js +0 -153
- package/dist/src/main.d.ts +0 -7
- package/dist/src/main.js +0 -1180
- package/dist/src/orchestrator.d.ts +0 -275
- package/dist/src/orchestrator.js +0 -579
- package/dist/src/prompt.d.ts +0 -13
- package/dist/src/prompt.js +0 -55
- package/dist/src/providers/providers.d.ts +0 -3
- package/dist/src/providers/providers.js +0 -210
- package/dist/src/providers/run-agent.d.ts +0 -34
- package/dist/src/providers/run-agent.js +0 -57
- package/dist/src/providers/types.d.ts +0 -27
- package/dist/src/providers/types.js +0 -6
- package/dist/src/resolved-config.d.ts +0 -8
- package/dist/src/resolved-config.js +0 -49
- package/dist/src/supervisor-decision.d.ts +0 -70
- package/dist/src/supervisor-decision.js +0 -94
- package/templates/container/compose.daemon.yml +0 -16
- package/templates/container/supervisor.sh +0 -50
- /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/)
|
|
96
|
+
Docs live under [`docs/`](docs/):
|
|
30
97
|
|
|
31
|
-
- `docs/architecture.md` — topology, worktree isolation,
|
|
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,383 @@
|
|
|
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 { existsSync } from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { installDrainSignal } from "../src/drain.ts";
|
|
29
|
+
import { defaultGit, type GitRunner } from "../src/git-model.ts";
|
|
30
|
+
import { loadUserConfig, resolveConfigPath } from "../src/load-config.ts";
|
|
31
|
+
import {
|
|
32
|
+
crashLoopStatePath,
|
|
33
|
+
createCrashGuard,
|
|
34
|
+
readStateDir,
|
|
35
|
+
DEFAULT_STATE_DIR,
|
|
36
|
+
type CrashGuard,
|
|
37
|
+
type CrashGuardEvent,
|
|
38
|
+
type RunOutcome,
|
|
39
|
+
} from "./crash-loop.ts";
|
|
40
|
+
import { readEngineSource, type ResolvedEngineSource } from "./engine-source.ts";
|
|
41
|
+
import { lsRemoteBranchSha, materializeGithubEngine } from "./github-engine.ts";
|
|
42
|
+
import {
|
|
43
|
+
configFingerprint,
|
|
44
|
+
superviseEngine,
|
|
45
|
+
CRASH_BACKOFF_MS,
|
|
46
|
+
DEFAULT_RECONCILE_INTERVAL_MS,
|
|
47
|
+
type EngineExit,
|
|
48
|
+
type EngineRun,
|
|
49
|
+
type LaunchedEngine,
|
|
50
|
+
type SupervisedChild,
|
|
51
|
+
} from "./reconcile.ts";
|
|
52
|
+
// Untyped plain-JS import (see spawn-engine.mjs / materialize.mjs for why the
|
|
53
|
+
// bootstrapper's child-process plumbing can't be TypeScript).
|
|
54
|
+
import { propagateExit, spawnEngine } from "./spawn-engine.mjs";
|
|
55
|
+
|
|
56
|
+
/** Where the local-engine compose overlay mounts the engine for `source: "local"`. */
|
|
57
|
+
export const LOCAL_ENGINE_DIR = "/opt/phoebe-engine";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a `local` engine source to the mounted engine's `src/cli.ts`, failing
|
|
61
|
+
* loudly if it is absent — a missing/empty mount means a misconfigured
|
|
62
|
+
* container, not a fallback. Checking the entry file (not just the directory)
|
|
63
|
+
* catches a mounted-but-empty volume too. `github` is handled separately
|
|
64
|
+
* (materializeGithubEngine), so this only ever sees `local`.
|
|
65
|
+
*
|
|
66
|
+
* `exists`/`localEngineDir` are injectable so the decision is unit-tested
|
|
67
|
+
* without a real filesystem.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveEngineEntry(
|
|
70
|
+
_source: { source: "local" },
|
|
71
|
+
deps: { localEngineDir?: string; exists?: (path: string) => boolean } = {},
|
|
72
|
+
): string {
|
|
73
|
+
const exists = deps.exists ?? existsSync;
|
|
74
|
+
const dir = deps.localEngineDir ?? LOCAL_ENGINE_DIR;
|
|
75
|
+
const entry = join(dir, "src", "cli.ts");
|
|
76
|
+
if (!exists(entry)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`engine.source is "local" but no engine is mounted at ${dir} (missing ${entry}). ` +
|
|
79
|
+
`Mount the engine there (container/compose.local.yml) before \`phoebe boot\`.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return entry;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Base directory the github source clones the engine into. Reuses
|
|
87
|
+
* `PHOEBE_ENGINE_DIR` (the same knob bin.mjs materializes under); point it at a
|
|
88
|
+
* persistent volume so github clones survive restarts and later boots fetch
|
|
89
|
+
* instead of re-cloning. Defaults to a per-user temp dir for local dev.
|
|
90
|
+
*/
|
|
91
|
+
function engineBaseDir(): string {
|
|
92
|
+
return process.env["PHOEBE_ENGINE_DIR"] ?? join(tmpdir(), "phoebe-agent");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How often the reconcile watch samples the config and the tracked ref.
|
|
97
|
+
* `PHOEBE_RECONCILE_INTERVAL_MS` tightens it for dogfooding (the default is a
|
|
98
|
+
* minute, which is a long time to wait when demonstrating a relaunch).
|
|
99
|
+
*/
|
|
100
|
+
function reconcileIntervalMs(): number {
|
|
101
|
+
const raw = Number(process.env["PHOEBE_RECONCILE_INTERVAL_MS"]);
|
|
102
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_RECONCILE_INTERVAL_MS;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Load the mounted `phoebe.config.ts` as the arbitrary record the bootstrapper
|
|
107
|
+
* treats it as — it owns only two fields (`engine`, `paths.stateDir`), and the
|
|
108
|
+
* engine validates the rest once it is materialized and run. The fingerprint
|
|
109
|
+
* doubles as the ESM cache-bust key, so a re-read after an edit is genuinely a
|
|
110
|
+
* re-read.
|
|
111
|
+
*/
|
|
112
|
+
async function loadMountedConfig(
|
|
113
|
+
configPath: string,
|
|
114
|
+
fingerprint: string | null,
|
|
115
|
+
): Promise<Record<string, unknown>> {
|
|
116
|
+
const userConfig = await loadUserConfig(configPath, { reloadKey: fingerprint ?? undefined });
|
|
117
|
+
return userConfig as unknown as Record<string, unknown>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read the config and turn the engine source it names into something runnable —
|
|
122
|
+
* the whole of a (re)launch. Called once at boot and again for every reconcile,
|
|
123
|
+
* so an edited config is genuinely re-read (hence the fingerprint as the ESM
|
|
124
|
+
* cache-bust key) and a moved ref is genuinely re-fetched.
|
|
125
|
+
*
|
|
126
|
+
* The tracked ref's tip is materialized first even when a fallback is in force:
|
|
127
|
+
* the tip is what the guard's verdict is *about*, so resolving it is how boot
|
|
128
|
+
* notices both that the quarantine still applies and that the branch has moved
|
|
129
|
+
* past it. A fallback then checks out the last-good commit in the same clone.
|
|
130
|
+
*/
|
|
131
|
+
async function launchTarget(configPath: string, guard: CrashGuard): Promise<LaunchedEngine> {
|
|
132
|
+
const fingerprint = configFingerprint(configPath);
|
|
133
|
+
const source = readEngineSource(await loadMountedConfig(configPath, fingerprint));
|
|
134
|
+
const token = process.env["GH_TOKEN"];
|
|
135
|
+
const sample = () => ({
|
|
136
|
+
config: configFingerprint(configPath),
|
|
137
|
+
remoteSha: watchedRefSha(source, token),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (source.source === "local") {
|
|
141
|
+
const entry = resolveEngineEntry(source);
|
|
142
|
+
console.log(`[phoebe] boot: engine source "local" — exec ${entry} (long-running).`);
|
|
143
|
+
return { entry, sha: null, config: fingerprint, guarded: false, quarantinedSha: null, sample };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const guarded = isMovingBranch(source, token);
|
|
147
|
+
const baseDir = engineBaseDir();
|
|
148
|
+
let { entry, sha } = materializeGithubEngine(source, { baseDir, token });
|
|
149
|
+
|
|
150
|
+
let quarantinedSha: string | null = null;
|
|
151
|
+
const pin = guarded && sha !== null ? guard.fallbackFor(sha) : null;
|
|
152
|
+
if (pin !== null) {
|
|
153
|
+
quarantinedSha = sha;
|
|
154
|
+
({ entry, sha } = materializeGithubEngine({ ...source, ref: pin }, { baseDir, token }));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const provenance =
|
|
158
|
+
quarantinedSha !== null
|
|
159
|
+
? `${source.repo}@${source.ref} → last-good ${sha} (crash-loop fallback from ${quarantinedSha})`
|
|
160
|
+
: `${source.repo}@${source.ref}${sha ? ` (${sha})` : ""}`;
|
|
161
|
+
console.log(
|
|
162
|
+
`[phoebe] boot: engine source "github" ${provenance} — exec ${entry} (long-running).`,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
return { entry, sha, config: fingerprint, guarded, quarantinedSha, sample };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Does the crash-loop guard apply to this source? Only a moving branch is
|
|
170
|
+
* guarded: a local mount has no commit to pin, and a pinned SHA or tag means the
|
|
171
|
+
* operator chose that exact commit — quietly serving a different one would be
|
|
172
|
+
* worse than crash-looping visibly. `lsRemoteBranchSha` answers precisely that
|
|
173
|
+
* question (it yields a tip only for a branch) and short-circuits a pinned SHA
|
|
174
|
+
* without touching the network.
|
|
175
|
+
*
|
|
176
|
+
* A remote that will not answer leaves the guard off for this launch rather than
|
|
177
|
+
* failing it: materializing is about to make the same call, and its error is the
|
|
178
|
+
* one worth surfacing.
|
|
179
|
+
*/
|
|
180
|
+
export function isMovingBranch(
|
|
181
|
+
source: ResolvedEngineSource,
|
|
182
|
+
token: string | undefined,
|
|
183
|
+
git: GitRunner = defaultGit,
|
|
184
|
+
): boolean {
|
|
185
|
+
if (source.source === "local") return false;
|
|
186
|
+
try {
|
|
187
|
+
return lsRemoteBranchSha(source, { token, git }) !== null;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.warn(
|
|
190
|
+
`[phoebe] boot: could not check whether ${source.repo}@${source.ref} is a moving branch — ` +
|
|
191
|
+
`${describe(error)}. Crash-loop fallback is off for this launch.`,
|
|
192
|
+
);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The ref half of a poll: where the tracked branch points now, or null when
|
|
199
|
+
* there is nothing to watch (a local mount, or a pinned SHA/tag — which the
|
|
200
|
+
* ref-watch leaves alone by design).
|
|
201
|
+
*/
|
|
202
|
+
function watchedRefSha(source: ResolvedEngineSource, token: string | undefined): string | null {
|
|
203
|
+
if (source.source === "local") return null;
|
|
204
|
+
return lsRemoteBranchSha(source, { token });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Spawn the engine and expose it as the supervisor's child handle. Both a normal
|
|
209
|
+
* exit and a spawn failure settle `exited` — the failure as a non-zero exit — so
|
|
210
|
+
* the supervisor always sees the child resolve and decides what to do (a
|
|
211
|
+
* first-launch failure is fatal, a relaunch failure retries). Without the
|
|
212
|
+
* `onSpawnError` override, spawn-engine.mjs's default would `process.exit(1)`
|
|
213
|
+
* here, bypassing boot's drain-latch teardown and leaving `exited` pending.
|
|
214
|
+
*/
|
|
215
|
+
function spawnSupervised(entry: string, argv: readonly string[]): SupervisedChild {
|
|
216
|
+
let settle!: (exit: EngineExit) => void;
|
|
217
|
+
const exited = new Promise<EngineExit>((resolve) => {
|
|
218
|
+
settle = resolve;
|
|
219
|
+
});
|
|
220
|
+
const child = spawnEngine(entry, argv, {
|
|
221
|
+
onExit: (code: number | null, signal: NodeJS.Signals | null) => settle({ code, signal }),
|
|
222
|
+
onSpawnError: (error: Error) => {
|
|
223
|
+
console.error(`[phoebe] boot: engine failed to spawn — ${error.message}`);
|
|
224
|
+
settle({ code: 1, signal: null });
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
return { kill: (signal) => child.kill(signal), exited };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The crash-loop guard for this container, rooted at the engine's state dir
|
|
232
|
+
* (`paths.stateDir`, a named volume). Resolved once from the config as it reads
|
|
233
|
+
* at boot: the record has to be found again after the restart a crash-looping
|
|
234
|
+
* engine causes, so where it lives must not drift with a mid-flight config edit.
|
|
235
|
+
* A config that will not load falls back to the shipped default here and fails
|
|
236
|
+
* properly on the first launch, where the error belongs.
|
|
237
|
+
*/
|
|
238
|
+
async function createBootCrashGuard(configPath: string): Promise<CrashGuard> {
|
|
239
|
+
let stateDir = DEFAULT_STATE_DIR;
|
|
240
|
+
try {
|
|
241
|
+
stateDir = readStateDir(await loadMountedConfig(configPath, configFingerprint(configPath)));
|
|
242
|
+
} catch {
|
|
243
|
+
// launchTarget loads the same config a moment later and reports the failure.
|
|
244
|
+
}
|
|
245
|
+
return createCrashGuard({
|
|
246
|
+
statePath: crashLoopStatePath(stateDir),
|
|
247
|
+
onEvent: logCrashGuardEvent,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The guard's decisions, in an operator's terms. A container quietly serving
|
|
253
|
+
* older code than its config asks for is exactly the confusion these lines
|
|
254
|
+
* exist to prevent, so every fallback event names both commits.
|
|
255
|
+
*/
|
|
256
|
+
function logCrashGuardEvent(event: CrashGuardEvent): void {
|
|
257
|
+
switch (event.kind) {
|
|
258
|
+
case "crash":
|
|
259
|
+
console.error(
|
|
260
|
+
`[phoebe] boot: engine ${event.sha} exited ${event.exitCode} after ` +
|
|
261
|
+
`${Math.round(event.elapsedMs / 1000)}s — fast crash ${event.failureCount}/${event.threshold}.`,
|
|
262
|
+
);
|
|
263
|
+
return;
|
|
264
|
+
case "last-good":
|
|
265
|
+
console.log(
|
|
266
|
+
`[phoebe] boot: engine ${event.sha} ran healthily — recorded as the crash-loop fallback target.`,
|
|
267
|
+
);
|
|
268
|
+
return;
|
|
269
|
+
case "fallback":
|
|
270
|
+
console.error(
|
|
271
|
+
`[phoebe] boot: engine ${event.quarantinedSha} crash-looped ${event.failureCount}× — ` +
|
|
272
|
+
`falling back to last-good ${event.lastGoodSha}, and staying there until the tracked ` +
|
|
273
|
+
`ref moves past the bad commit.`,
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
case "fallback-crashed":
|
|
277
|
+
console.error(
|
|
278
|
+
`[phoebe] boot: the last-good engine ${event.sha} crashed too ` +
|
|
279
|
+
`(exit ${event.exitCode} after ${Math.round(event.elapsedMs / 1000)}s) — ` +
|
|
280
|
+
`${event.quarantinedSha} stays quarantined and the container will exit.`,
|
|
281
|
+
);
|
|
282
|
+
return;
|
|
283
|
+
case "recovered":
|
|
284
|
+
console.log(
|
|
285
|
+
`[phoebe] boot: tracked ref advanced to ${event.sha}, past quarantined ` +
|
|
286
|
+
`${event.quarantinedSha} — crash-loop fallback lifted.`,
|
|
287
|
+
);
|
|
288
|
+
return;
|
|
289
|
+
case "persist-failed":
|
|
290
|
+
console.warn(
|
|
291
|
+
`[phoebe] boot: could not write crash-loop state to ${event.path} — ` +
|
|
292
|
+
`${describe(event.error)}. The fallback will not survive a container restart.`,
|
|
293
|
+
);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A finished run as the crash-loop guard sees it, or null when there is no
|
|
300
|
+
* commit to say anything about (a local mount). Note this is *not* gated on
|
|
301
|
+
* `guarded`: what a pinned launch proved is still worth remembering — it only
|
|
302
|
+
* must not cause a fallback — and recording it means an operator who later moves
|
|
303
|
+
* that deployment onto a branch already has a target to fall back to.
|
|
304
|
+
*/
|
|
305
|
+
function runOutcome(run: EngineRun): RunOutcome | null {
|
|
306
|
+
if (run.engine.sha === null) return null;
|
|
307
|
+
return {
|
|
308
|
+
sha: run.engine.sha,
|
|
309
|
+
exitCode: run.exit.code,
|
|
310
|
+
elapsedMs: run.elapsedMs,
|
|
311
|
+
requestedStop: run.requestedStop,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* `phoebe boot` entry. Loads the mounted config, resolves the engine source to a
|
|
317
|
+
* runnable `src/cli.ts` — a local mount or a github checkout — execs the engine
|
|
318
|
+
* as a long-lived child, and supervises it: reconcile relaunches on a config or
|
|
319
|
+
* ref change, the crash-loop guard pins back to the last-good commit when the
|
|
320
|
+
* tracked ref will not boot, and a container stop drains it and exits with its
|
|
321
|
+
* status. Extra args after `boot` are forwarded to the engine (none ⇒ the
|
|
322
|
+
* persistent loop).
|
|
323
|
+
*/
|
|
324
|
+
export async function runBoot(argv: readonly string[]): Promise<void> {
|
|
325
|
+
const configPath = resolveConfigPath(undefined, process.cwd());
|
|
326
|
+
const guard = await createBootCrashGuard(configPath);
|
|
327
|
+
const intervalMs = reconcileIntervalMs();
|
|
328
|
+
|
|
329
|
+
// The container's stop request. A one-way latch, and the poll clock: a
|
|
330
|
+
// SIGTERM mid-poll wakes the watch immediately instead of sleeping out the
|
|
331
|
+
// interval. Holding these listeners also keeps boot alive across the moment
|
|
332
|
+
// between an engine exiting and its replacement spawning, where the child's
|
|
333
|
+
// own forwarders are not installed.
|
|
334
|
+
const stop = installDrainSignal(process, ["SIGTERM", "SIGINT"]);
|
|
335
|
+
let exit: EngineExit;
|
|
336
|
+
try {
|
|
337
|
+
exit = await superviseEngine({
|
|
338
|
+
launch: () => launchTarget(configPath, guard),
|
|
339
|
+
spawn: (entry) => spawnSupervised(entry, argv),
|
|
340
|
+
stop,
|
|
341
|
+
intervalMs,
|
|
342
|
+
onRunEnd: (run) => {
|
|
343
|
+
const outcome = runOutcome(run);
|
|
344
|
+
if (outcome !== null) guard.record(outcome);
|
|
345
|
+
},
|
|
346
|
+
onRunTick: ({ engine, elapsedMs }) => {
|
|
347
|
+
if (engine.sha !== null) guard.noteAlive(engine.sha, elapsedMs);
|
|
348
|
+
},
|
|
349
|
+
relaunchAfterExit: (run) => {
|
|
350
|
+
// Only a guarded launch retries: a pinned ref that crashes takes the
|
|
351
|
+
// container down, exactly as it did before there was a guard.
|
|
352
|
+
const outcome = run.engine.guarded ? runOutcome(run) : null;
|
|
353
|
+
if (outcome === null || !guard.shouldRetry(outcome)) return false;
|
|
354
|
+
console.log(
|
|
355
|
+
`[phoebe] boot: relaunching the engine in ${Math.round(CRASH_BACKOFF_MS / 1000)}s — ` +
|
|
356
|
+
`a last-good engine commit is available to fall back to.`,
|
|
357
|
+
);
|
|
358
|
+
return true;
|
|
359
|
+
},
|
|
360
|
+
onRelaunch: (reason) =>
|
|
361
|
+
console.log(
|
|
362
|
+
reason === "config"
|
|
363
|
+
? "[phoebe] boot: mounted config changed — draining the engine (SIGTERM) and relaunching."
|
|
364
|
+
: "[phoebe] boot: tracked ref advanced — draining the engine (SIGTERM) and relaunching.",
|
|
365
|
+
),
|
|
366
|
+
onLaunchError: (error) =>
|
|
367
|
+
console.error(
|
|
368
|
+
`[phoebe] boot: could not launch the engine — ${describe(error)}. Retrying next poll.`,
|
|
369
|
+
),
|
|
370
|
+
onSampleError: (error) =>
|
|
371
|
+
console.warn(`[phoebe] boot: reconcile poll failed — ${describe(error)}. Ignoring.`),
|
|
372
|
+
});
|
|
373
|
+
} finally {
|
|
374
|
+
// Drop the listeners before propagating: re-raising the engine's killing
|
|
375
|
+
// signal must actually kill this process, and our own latch would swallow it.
|
|
376
|
+
stop.dispose();
|
|
377
|
+
}
|
|
378
|
+
propagateExit(exit.code, exit.signal);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function describe(error: unknown): string {
|
|
382
|
+
return error instanceof Error ? error.message : String(error);
|
|
383
|
+
}
|
package/bootstrap/cli.ts
ADDED
|
@@ -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
|
+
});
|