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
@@ -1,210 +0,0 @@
1
- // The three agent CLIs Phoebe can drive. Invocation flags and stream-JSON
2
- // schemas were authored from Sandcastle 0.8.0's AgentProvider.ts (the
3
- // `@ai-hero/sandcastle` package sources) and carry its field-level
4
- // knowledge of each CLI's output format.
5
- /** Maps allowlisted tool names to the input field carrying the display arg. */
6
- const TOOL_ARG_FIELDS = {
7
- Bash: "command",
8
- WebSearch: "query",
9
- WebFetch: "url",
10
- Agent: "description",
11
- };
12
- /**
13
- * Claude Code and Cursor share the Claude stream-json line schema:
14
- * `assistant` messages with text/tool_use content blocks, plus a terminal
15
- * `result` event.
16
- */
17
- const parseClaudeStreamLine = (line) => {
18
- if (!line.startsWith("{"))
19
- return [];
20
- try {
21
- const obj = JSON.parse(line);
22
- const message = obj["message"];
23
- if (obj["type"] === "assistant" && Array.isArray(message?.content)) {
24
- const events = [];
25
- const texts = [];
26
- for (const block of message.content) {
27
- if (block.type === "text" && typeof block.text === "string") {
28
- texts.push(block.text);
29
- }
30
- else if (block.type === "tool_use" &&
31
- typeof block.name === "string" &&
32
- block.input !== undefined) {
33
- const argField = TOOL_ARG_FIELDS[block.name];
34
- if (argField === undefined)
35
- continue;
36
- const argValue = block.input[argField];
37
- if (typeof argValue !== "string")
38
- continue;
39
- if (texts.length > 0) {
40
- events.push({ type: "text", text: texts.join("") });
41
- texts.length = 0;
42
- }
43
- events.push({ type: "tool_call", name: block.name, args: argValue });
44
- }
45
- }
46
- if (texts.length > 0) {
47
- events.push({ type: "text", text: texts.join("") });
48
- }
49
- return events;
50
- }
51
- if (obj["type"] === "result" && typeof obj["result"] === "string") {
52
- return [{ type: "result", result: obj["result"] }];
53
- }
54
- }
55
- catch {
56
- // Not valid JSON — skip.
57
- }
58
- return [];
59
- };
60
- /** Cursor additionally emits top-level `tool_call` events with per-tool shapes. */
61
- const parseCursorStreamLine = (line) => {
62
- if (!line.startsWith("{"))
63
- return [];
64
- let obj;
65
- try {
66
- obj = JSON.parse(line);
67
- }
68
- catch {
69
- return [];
70
- }
71
- if (obj["type"] !== "tool_call" || obj["subtype"] !== "started") {
72
- return parseClaudeStreamLine(line);
73
- }
74
- const toolCall = obj["tool_call"];
75
- if (!toolCall || typeof toolCall !== "object")
76
- return [];
77
- const tc = toolCall;
78
- const readToolCall = tc["readToolCall"];
79
- if (readToolCall?.args && typeof readToolCall.args.path === "string") {
80
- return [{ type: "tool_call", name: "Read", args: readToolCall.args.path }];
81
- }
82
- const writeToolCall = tc["writeToolCall"];
83
- if (writeToolCall?.args && typeof writeToolCall.args.path === "string") {
84
- return [{ type: "tool_call", name: "Write", args: writeToolCall.args.path }];
85
- }
86
- const fn = tc["function"];
87
- if (fn && typeof fn.name === "string") {
88
- const rawArgs = typeof fn.arguments === "string" ? fn.arguments : "";
89
- if (rawArgs) {
90
- try {
91
- const parsedArgs = JSON.parse(rawArgs);
92
- if (typeof parsedArgs["command"] === "string") {
93
- return [{ type: "tool_call", name: "Bash", args: parsedArgs["command"] }];
94
- }
95
- }
96
- catch {
97
- // Fall through to the raw arguments string.
98
- }
99
- return [{ type: "tool_call", name: fn.name, args: rawArgs }];
100
- }
101
- return [{ type: "tool_call", name: fn.name, args: "" }];
102
- }
103
- return [];
104
- };
105
- const extractErrorMessage = (obj) => {
106
- const err = obj["error"];
107
- if (typeof err === "string")
108
- return err;
109
- if (typeof err === "object" && err !== null) {
110
- const e = err;
111
- if (typeof e.message === "string")
112
- return e.message;
113
- if (typeof e.data?.message === "string")
114
- return e.data.message;
115
- }
116
- if (typeof obj["message"] === "string")
117
- return obj["message"];
118
- return undefined;
119
- };
120
- /** Codex `exec --json` emits item/turn events rather than message blocks. */
121
- const parseCodexStreamLine = (line) => {
122
- if (!line.startsWith("{"))
123
- return [];
124
- try {
125
- const obj = JSON.parse(line);
126
- const item = obj["item"];
127
- if (obj["type"] === "item.completed" &&
128
- item?.type === "agent_message" &&
129
- typeof item.text === "string") {
130
- return [
131
- { type: "text", text: item.text },
132
- { type: "result", result: item.text },
133
- ];
134
- }
135
- if (obj["type"] === "item.started" &&
136
- item?.type === "command_execution" &&
137
- typeof item.command === "string") {
138
- return [{ type: "tool_call", name: "Bash", args: item.command }];
139
- }
140
- // Codex reports auth/rate-limit/API errors on stdout, not stderr.
141
- if (obj["type"] === "error") {
142
- const msg = extractErrorMessage(obj);
143
- return msg ? [{ type: "result", result: msg }] : [];
144
- }
145
- }
146
- catch {
147
- // Not valid JSON — skip.
148
- }
149
- return [];
150
- };
151
- /**
152
- * The Cursor CLI takes the prompt as a positional argv argument (stdin is not
153
- * documented for prompt delivery). Linux caps a single argument at ~128 KiB;
154
- * stay under it so users get a clear error instead of spawn E2BIG.
155
- */
156
- const CURSOR_PROMPT_MAX_BYTES = 120 * 1024;
157
- const cursor = {
158
- name: "cursor",
159
- buildCommand({ prompt, model }) {
160
- const bytes = Buffer.byteLength(prompt, "utf8");
161
- if (bytes > CURSOR_PROMPT_MAX_BYTES) {
162
- throw new Error(`Cursor prompt is ${bytes} bytes (max ${CURSOR_PROMPT_MAX_BYTES}). The Cursor CLI only accepts the prompt as an argv argument; shorten the prompt or use another provider.`);
163
- }
164
- return {
165
- argv: [
166
- "agent",
167
- "--print",
168
- "--output-format",
169
- "stream-json",
170
- "--model",
171
- model,
172
- "--force",
173
- prompt,
174
- ],
175
- };
176
- },
177
- parseStreamLine: parseCursorStreamLine,
178
- };
179
- const claude = {
180
- name: "claude",
181
- buildCommand({ prompt, model }) {
182
- return {
183
- argv: [
184
- "claude",
185
- "--print",
186
- "--verbose",
187
- "--dangerously-skip-permissions",
188
- "--output-format",
189
- "stream-json",
190
- "--model",
191
- model,
192
- "-p",
193
- "-",
194
- ],
195
- stdin: prompt,
196
- };
197
- },
198
- parseStreamLine: parseClaudeStreamLine,
199
- };
200
- const codex = {
201
- name: "codex",
202
- buildCommand({ prompt, model }) {
203
- return {
204
- argv: ["codex", "exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "-m", model],
205
- stdin: prompt,
206
- };
207
- },
208
- parseStreamLine: parseCodexStreamLine,
209
- };
210
- export const PROVIDERS = { cursor, claude, codex };
@@ -1,34 +0,0 @@
1
- import type { Provider } from "./types.ts";
2
- export type AgentRunResult = {
3
- exitCode: number;
4
- /** Last `result` event the provider emitted, if any. */
5
- resultText: string;
6
- };
7
- /** Minimal child-process surface so tests can inject a fake spawn. */
8
- export type AgentChild = {
9
- stdout: {
10
- on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
11
- };
12
- stderr: {
13
- on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
14
- };
15
- stdin: {
16
- write(data: string): unknown;
17
- end(): unknown;
18
- };
19
- on(event: "error", listener: (err: Error) => void): unknown;
20
- on(event: "close", listener: (code: number | null) => void): unknown;
21
- };
22
- export type SpawnAgent = (file: string, args: readonly string[], opts: {
23
- cwd: string;
24
- env: Record<string, string>;
25
- }) => AgentChild;
26
- export declare function runAgent(opts: {
27
- provider: Provider;
28
- model: string;
29
- prompt: string;
30
- cwd: string;
31
- env: Record<string, string>;
32
- spawn?: SpawnAgent;
33
- log?: (line: string) => void;
34
- }): Promise<AgentRunResult>;
@@ -1,57 +0,0 @@
1
- // Spawn an agent CLI for one work unit and stream its output. The child's
2
- // argv/stdin come from the provider; the env is the caller-built allowlist
3
- // (see ../agent-env.ts) — never the orchestrator's full process.env.
4
- import { spawn as nodeSpawn } from "node:child_process";
5
- const defaultSpawn = (file, args, opts) => nodeSpawn(file, args, { ...opts, stdio: ["pipe", "pipe", "pipe"] });
6
- export async function runAgent(opts) {
7
- const { provider, model, prompt, cwd, env } = opts;
8
- const spawn = opts.spawn ?? defaultSpawn;
9
- const log = opts.log ?? ((line) => console.log(line));
10
- const command = provider.buildCommand({ prompt, model });
11
- const [file, ...args] = command.argv;
12
- if (!file) {
13
- throw new Error(`Provider "${provider.name}" built an empty command.`);
14
- }
15
- return new Promise((resolve, reject) => {
16
- const child = spawn(file, args, { cwd, env });
17
- let resultText = "";
18
- let stdoutBuffer = "";
19
- const handleLine = (line) => {
20
- for (const event of provider.parseStreamLine(line)) {
21
- if (event.type === "text") {
22
- const text = event.text.trim();
23
- if (text)
24
- log(`[${provider.name}] ${text}`);
25
- }
26
- else if (event.type === "tool_call") {
27
- log(`[${provider.name}] ${event.name}: ${event.args}`);
28
- }
29
- else {
30
- resultText = event.result;
31
- }
32
- }
33
- };
34
- child.stdout.on("data", (chunk) => {
35
- stdoutBuffer += chunk.toString();
36
- const lines = stdoutBuffer.split("\n");
37
- stdoutBuffer = lines.pop() ?? "";
38
- for (const line of lines)
39
- handleLine(line);
40
- });
41
- child.stderr.on("data", (chunk) => {
42
- const text = chunk.toString().trim();
43
- if (text)
44
- log(`[${provider.name}:stderr] ${text}`);
45
- });
46
- child.on("error", reject);
47
- child.on("close", (code) => {
48
- if (stdoutBuffer)
49
- handleLine(stdoutBuffer);
50
- resolve({ exitCode: code ?? 1, resultText });
51
- });
52
- if (command.stdin !== undefined) {
53
- child.stdin.write(command.stdin);
54
- }
55
- child.stdin.end();
56
- });
57
- }
@@ -1,27 +0,0 @@
1
- import type { ProviderName } from "../config-schema.ts";
2
- /** A ready-to-spawn CLI invocation. `argv[0]` is the binary; no shell involved. */
3
- export type AgentCommand = {
4
- readonly argv: readonly string[];
5
- /** When set, piped to the child's stdin (large prompts don't fit argv). */
6
- readonly stdin?: string;
7
- };
8
- /** Events parsed from one line of the agent's JSONL output stream. */
9
- export type AgentEvent = {
10
- type: "text";
11
- text: string;
12
- } | {
13
- type: "result";
14
- result: string;
15
- } | {
16
- type: "tool_call";
17
- name: string;
18
- args: string;
19
- };
20
- export type Provider = {
21
- readonly name: ProviderName;
22
- buildCommand(opts: {
23
- prompt: string;
24
- model: string;
25
- }): AgentCommand;
26
- parseStreamLine(line: string): AgentEvent[];
27
- };
@@ -1,6 +0,0 @@
1
- // Agent provider contract — how Phoebe invokes a coding-agent CLI and reads
2
- // its output stream. Ported from Sandcastle's AgentProvider design (Matt
3
- // Pocock, https://github.com/mattpocock/sandcastle — the design ancestor of
4
- // this engine), trimmed to what Phoebe uses: one print-mode run per work unit,
5
- // no session capture, no interactive mode.
6
- export {};
@@ -1,8 +0,0 @@
1
- import type { PhoebeConfig } from "./config-schema.ts";
2
- /** Install the resolved config. Idempotent — later calls replace the prior value. */
3
- export declare function setResolvedConfig(next: PhoebeConfig): void;
4
- /** For test isolation: clear the installed config back to the "not installed" state. */
5
- export declare function clearResolvedConfig(): void;
6
- /** Whether a resolved config has been installed. */
7
- export declare function hasResolvedConfig(): boolean;
8
- export declare const config: PhoebeConfig;
@@ -1,49 +0,0 @@
1
- // Runtime handle to the resolved, defaults-filled config the engine reads.
2
- //
3
- // The engine (everything else under src/) imports `config` from here and stays
4
- // repo-agnostic; the CLI (src/cli.ts) loads the consumer's `phoebe.config.ts`,
5
- // applies the `PHOEBE_*` env overlay, resolves defaults, and installs the
6
- // result via `setResolvedConfig` before importing the engine entry point.
7
- // Tests install a sample config via the setup file wired in `vite.config.ts`.
8
- //
9
- // A `Proxy` gates every field read: reading before install throws with a
10
- // pointer at the CLI/setup path, so an accidentally repo-coupled engine import
11
- // fails loudly at the callsite instead of silently reading `undefined`.
12
- let resolved = null;
13
- /** Install the resolved config. Idempotent — later calls replace the prior value. */
14
- export function setResolvedConfig(next) {
15
- resolved = next;
16
- }
17
- /** For test isolation: clear the installed config back to the "not installed" state. */
18
- export function clearResolvedConfig() {
19
- resolved = null;
20
- }
21
- /** Whether a resolved config has been installed. */
22
- export function hasResolvedConfig() {
23
- return resolved !== null;
24
- }
25
- export const config = new Proxy({}, {
26
- get(_target, prop) {
27
- if (resolved === null) {
28
- throw new Error(`Attempted to read config.${String(prop)} before the resolved config was installed. ` +
29
- `The Phoebe CLI (src/cli.ts) installs it after loading phoebe.config.ts; ` +
30
- `tests install a sample via src/test-setup.ts (wired from vite.config.ts).`);
31
- }
32
- return resolved[prop];
33
- },
34
- ownKeys() {
35
- if (resolved === null)
36
- return [];
37
- return Reflect.ownKeys(resolved);
38
- },
39
- getOwnPropertyDescriptor(_target, prop) {
40
- if (resolved === null)
41
- return undefined;
42
- return Reflect.getOwnPropertyDescriptor(resolved, prop);
43
- },
44
- has(_target, prop) {
45
- if (resolved === null)
46
- return false;
47
- return prop in resolved;
48
- },
49
- });
@@ -1,70 +0,0 @@
1
- /** Deliberate "I need a reinstall + re-exec" exit code, watched by container/supervisor.sh. */
2
- export declare const SELF_UPDATE_EXIT_CODE = 42;
3
- /** Consecutive fast crashes of a freshly-pulled SHA before the daemon falls
4
- * back to the last SHA that ran healthily. Mirrored in container/supervisor.sh. */
5
- export declare const CRASH_LOOP_THRESHOLD = 3;
6
- /** Seconds an orchestrator run must survive to count as healthy: a startup
7
- * crash from a bad pull dies well within this, a transient runtime error does
8
- * not. Mirrored in container/supervisor.sh. */
9
- export declare const HEALTHY_RUN_SECONDS = 60;
10
- /**
11
- * Whether any changed file means Phoebe's own code (or the lockfile behind its
12
- * dependencies) moved. `selfUpdatePaths` entries ending in `/` match as
13
- * directory prefixes; other entries match exactly.
14
- */
15
- export declare function shouldSelfUpdate(changedFiles: readonly string[], selfUpdatePaths: readonly string[]): boolean;
16
- /**
17
- * Whether the orchestrator should exit for a supervisor self-update. Same as
18
- * {@link shouldSelfUpdate}, except a run pinned to the last-good SHA (the
19
- * supervisor passes the crash-looping SHA it is quarantining) must not
20
- * self-update back into that quarantined commit: while `origin/<branch>` still
21
- * points at it, stay on the good code.
22
- */
23
- export declare function shouldExitForSelfUpdate(opts: {
24
- changedFiles: readonly string[];
25
- selfUpdatePaths: readonly string[];
26
- /** Current `origin/<defaultBranch>` SHA. */
27
- originSha: string;
28
- /** The crash-looping SHA the supervisor is avoiding, if any. */
29
- quarantinedSha?: string | null;
30
- }): boolean;
31
- /** Crash-loop bookkeeping, persisted across container restarts on /data/state. */
32
- export type CrashLoopState = {
33
- /** SHA that last ran healthily — the fallback target. */
34
- lastGoodSha: string | null;
35
- /** SHA currently accumulating fast-crash counts (or quarantined as bad). */
36
- failingSha: string | null;
37
- /** Consecutive fast crashes recorded for `failingSha`. */
38
- failureCount: number;
39
- };
40
- export declare const INITIAL_CRASH_LOOP_STATE: CrashLoopState;
41
- /**
42
- * Whether the supervisor should abandon `target` and re-run the last known-good
43
- * SHA — true only once `target` has fast-crashed `threshold` times and a
44
- * *different* good SHA exists to fall back to (if the good SHA is `target`
45
- * itself, falling back changes nothing, so we keep retrying `target`).
46
- */
47
- export declare function shouldFallBack(target: string, state: CrashLoopState, threshold?: number): boolean;
48
- /** The SHA the supervisor should check out and run this iteration. */
49
- export declare function chooseRunSha(target: string, state: CrashLoopState, threshold?: number): string;
50
- export type RunOutcome = {
51
- /** SHA that was run. */
52
- sha: string;
53
- /** Orchestrator exit code. */
54
- exitCode: number;
55
- /** Seconds the run survived before exiting. */
56
- elapsedSeconds: number;
57
- };
58
- /**
59
- * A run is healthy — its code booted and worked — if it self-updated, exited
60
- * cleanly, or survived the healthy window before exiting. Only a fast non-zero,
61
- * non-self-update exit is a crash that counts toward the fallback threshold.
62
- */
63
- export declare function isHealthyRun(outcome: RunOutcome, healthySeconds?: number): boolean;
64
- /**
65
- * Fold a completed run into the crash-loop state. A healthy run becomes the new
66
- * last-good, but a quarantine of a *different* crash-looping SHA is preserved
67
- * (a fallback run of the good SHA must not clear the bad SHA's record). A fast
68
- * crash increments the count for its SHA, resetting when the failing SHA moves.
69
- */
70
- export declare function recordRun(state: CrashLoopState, outcome: RunOutcome, healthySeconds?: number): CrashLoopState;
@@ -1,94 +0,0 @@
1
- // Pure self-update + crash-loop decisions. The container's shell supervisor
2
- // stays dumb; these functions are the tested specification it mirrors.
3
- //
4
- // - shouldSelfUpdate / shouldExitForSelfUpdate — after each cycle's fetch,
5
- // did Phoebe's own code move on the default branch? If so the orchestrator
6
- // exits with SELF_UPDATE_EXIT_CODE and the supervisor reinstalls + re-execs.
7
- // - chooseRunSha / recordRun — the daemon's last-good crash-loop fallback:
8
- // if a freshly-pulled SHA keeps dying on startup, pin to the last SHA that
9
- // ran healthily until the default branch advances past the bad one.
10
- //
11
- // The supervisor mirrors this logic in POSIX sh (container/supervisor.sh)
12
- // rather than calling it, so the fallback survives the very failure it guards
13
- // against — a bad pull that makes this TypeScript itself fail to run. Keep the
14
- // two in sync; the tests here are the reference semantics.
15
- /** Deliberate "I need a reinstall + re-exec" exit code, watched by container/supervisor.sh. */
16
- export const SELF_UPDATE_EXIT_CODE = 42;
17
- /** Consecutive fast crashes of a freshly-pulled SHA before the daemon falls
18
- * back to the last SHA that ran healthily. Mirrored in container/supervisor.sh. */
19
- export const CRASH_LOOP_THRESHOLD = 3;
20
- /** Seconds an orchestrator run must survive to count as healthy: a startup
21
- * crash from a bad pull dies well within this, a transient runtime error does
22
- * not. Mirrored in container/supervisor.sh. */
23
- export const HEALTHY_RUN_SECONDS = 60;
24
- /**
25
- * Whether any changed file means Phoebe's own code (or the lockfile behind its
26
- * dependencies) moved. `selfUpdatePaths` entries ending in `/` match as
27
- * directory prefixes; other entries match exactly.
28
- */
29
- export function shouldSelfUpdate(changedFiles, selfUpdatePaths) {
30
- return changedFiles.some((file) => selfUpdatePaths.some((path) => (path.endsWith("/") ? file.startsWith(path) : file === path)));
31
- }
32
- /**
33
- * Whether the orchestrator should exit for a supervisor self-update. Same as
34
- * {@link shouldSelfUpdate}, except a run pinned to the last-good SHA (the
35
- * supervisor passes the crash-looping SHA it is quarantining) must not
36
- * self-update back into that quarantined commit: while `origin/<branch>` still
37
- * points at it, stay on the good code.
38
- */
39
- export function shouldExitForSelfUpdate(opts) {
40
- if (opts.quarantinedSha && opts.originSha === opts.quarantinedSha)
41
- return false;
42
- return shouldSelfUpdate(opts.changedFiles, opts.selfUpdatePaths);
43
- }
44
- export const INITIAL_CRASH_LOOP_STATE = {
45
- lastGoodSha: null,
46
- failingSha: null,
47
- failureCount: 0,
48
- };
49
- /**
50
- * Whether the supervisor should abandon `target` and re-run the last known-good
51
- * SHA — true only once `target` has fast-crashed `threshold` times and a
52
- * *different* good SHA exists to fall back to (if the good SHA is `target`
53
- * itself, falling back changes nothing, so we keep retrying `target`).
54
- */
55
- export function shouldFallBack(target, state, threshold = CRASH_LOOP_THRESHOLD) {
56
- return (state.failingSha === target &&
57
- state.failureCount >= threshold &&
58
- state.lastGoodSha !== null &&
59
- state.lastGoodSha !== target);
60
- }
61
- /** The SHA the supervisor should check out and run this iteration. */
62
- export function chooseRunSha(target, state, threshold = CRASH_LOOP_THRESHOLD) {
63
- return shouldFallBack(target, state, threshold) ? state.lastGoodSha : target;
64
- }
65
- /**
66
- * A run is healthy — its code booted and worked — if it self-updated, exited
67
- * cleanly, or survived the healthy window before exiting. Only a fast non-zero,
68
- * non-self-update exit is a crash that counts toward the fallback threshold.
69
- */
70
- export function isHealthyRun(outcome, healthySeconds = HEALTHY_RUN_SECONDS) {
71
- return (outcome.exitCode === SELF_UPDATE_EXIT_CODE ||
72
- outcome.exitCode === 0 ||
73
- outcome.elapsedSeconds >= healthySeconds);
74
- }
75
- /**
76
- * Fold a completed run into the crash-loop state. A healthy run becomes the new
77
- * last-good, but a quarantine of a *different* crash-looping SHA is preserved
78
- * (a fallback run of the good SHA must not clear the bad SHA's record). A fast
79
- * crash increments the count for its SHA, resetting when the failing SHA moves.
80
- */
81
- export function recordRun(state, outcome, healthySeconds = HEALTHY_RUN_SECONDS) {
82
- if (isHealthyRun(outcome, healthySeconds)) {
83
- const stillQuarantining = state.failingSha !== null && state.failingSha !== outcome.sha;
84
- return {
85
- lastGoodSha: outcome.sha,
86
- failingSha: stillQuarantining ? state.failingSha : null,
87
- failureCount: stillQuarantining ? state.failureCount : 0,
88
- };
89
- }
90
- if (state.failingSha === outcome.sha) {
91
- return { ...state, failureCount: state.failureCount + 1 };
92
- }
93
- return { lastGoodSha: state.lastGoodSha, failingSha: outcome.sha, failureCount: 1 };
94
- }
@@ -1,16 +0,0 @@
1
- # Persistent-daemon overlay — scaffolded by `phoebe init`, consumer-owned.
2
- #
3
- # Layer on top of compose.yml to run Phoebe as a long-lived poller:
4
- #
5
- # docker compose -f compose.yml -f compose.daemon.yml up -d
6
- #
7
- # Drops the `--run-once` flag so the engine enters its polling loop, and
8
- # adds a restart policy so the container comes back after a crash or host
9
- # reboot. The engine's own self-update path uses `SELF_UPDATE_EXIT_CODE`
10
- # (75), which the supervisor handles inside the container without needing
11
- # a restart.
12
-
13
- services:
14
- phoebe:
15
- command: []
16
- restart: unless-stopped
@@ -1,50 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Phoebe supervisor — scaffolded by `phoebe init`, consumer-owned.
3
- #
4
- # Keeps the engine (`{{CLI_BIN}}`) restarted when it exits deliberately for a
5
- # self-update, and re-runs the consumer's install command (`{{INSTALL_COMMAND}}`)
6
- # after every self-update so the private clone stays warm. Exits 0 only when
7
- # the engine returns 0 in one-shot mode.
8
-
9
- set -euo pipefail
10
-
11
- : "${PHOEBE_REPO_DIR:=/data/repo}"
12
- : "${PHOEBE_STATE_DIR:=/data/state}"
13
-
14
- # Exit code the engine uses to request a supervisor re-exec after a self-update
15
- # (kept in sync with SELF_UPDATE_EXIT_CODE in src/supervisor-decision.ts).
16
- SELF_UPDATE_EXIT_CODE=75
17
-
18
- mkdir -p "${PHOEBE_REPO_DIR}" "${PHOEBE_STATE_DIR}"
19
-
20
- warm_install() {
21
- # Best-effort: on first boot the clone doesn't exist yet, and the engine will
22
- # populate it on its first tick. Skip silently when there's nothing to install.
23
- if [ -f "${PHOEBE_REPO_DIR}/package.json" ]; then
24
- ( cd "${PHOEBE_REPO_DIR}" && {{INSTALL_COMMAND}} ) || true
25
- fi
26
- }
27
-
28
- while true; do
29
- warm_install
30
- set +e
31
- {{CLI_BIN}} "$@"
32
- status=$?
33
- set -e
34
-
35
- if [ "${status}" -eq "${SELF_UPDATE_EXIT_CODE}" ]; then
36
- echo "[phoebe-supervisor] Engine exited ${SELF_UPDATE_EXIT_CODE} — reinstalling and re-execing."
37
- npm install -g "{{CLI_BIN}}@${PHOEBE_VERSION:-latest}" || true
38
- continue
39
- fi
40
-
41
- # Non-self-update exit — one-shot runs propagate the exit code, persistent
42
- # runs treat any exit as unexpected and get restarted after a short backoff.
43
- case " $* " in
44
- *" --run-once "*|*" --dry-run "*)
45
- exit "${status}"
46
- ;;
47
- esac
48
- echo "[phoebe-supervisor] Engine exited ${status} — restarting in 10s."
49
- sleep 10
50
- done
File without changes