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
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Type surface of the `phoebe-agent` bootstrapper package — the `types` entry.
|
|
2
|
+
// The runtime entry is index.mjs (plain JS, because Node 24 won't type-strip the
|
|
3
|
+
// installed package under node_modules); this file exists only to type a
|
|
4
|
+
// consumer's config authoring:
|
|
5
|
+
//
|
|
6
|
+
// ```ts
|
|
7
|
+
// import { defineConfig, type PhoebeUserConfig } from "phoebe-agent";
|
|
8
|
+
// export default defineConfig({ repoSlug: "...", ... });
|
|
9
|
+
// ```
|
|
10
|
+
//
|
|
11
|
+
// `defineConfig` is the identity typing helper; the config types are owned by
|
|
12
|
+
// the engine's config schema. The engine-source reader (engine-source.ts) is the
|
|
13
|
+
// bootstrapper's own internal concern — it is not part of the published surface
|
|
14
|
+
// yet, so it is not re-exported here.
|
|
15
|
+
|
|
16
|
+
export { defineConfig } from "./define-config.ts";
|
|
17
|
+
export type {
|
|
18
|
+
EngineSourceField,
|
|
19
|
+
PhoebeConfig,
|
|
20
|
+
PhoebeUserConfig,
|
|
21
|
+
PathsConfig,
|
|
22
|
+
PromptFilesConfig,
|
|
23
|
+
ProviderName,
|
|
24
|
+
} from "../src/config-schema.ts";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Copy the package out of node_modules so Node can run its raw `.ts`. Node 24
|
|
2
|
+
// refuses to type-strip any file under a `node_modules` segment
|
|
3
|
+
// (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), and the installed package lives
|
|
4
|
+
// exactly there. This is the one irreducible bit of the bootstrapper that must
|
|
5
|
+
// be plain JS: it runs first, still inside node_modules, and its whole job is to
|
|
6
|
+
// get the TypeScript bootstrapper (bootstrap/cli.ts) + engine (src/) to a
|
|
7
|
+
// directory *outside* node_modules, where type-stripping is allowed. Everything
|
|
8
|
+
// downstream — the bootstrapper and engine — is type-checked TypeScript.
|
|
9
|
+
//
|
|
10
|
+
// The bundled copy is the transitional engine source. Later tickets (#40/#41)
|
|
11
|
+
// teach bootstrap/cli.ts to resolve the engine from a local mount / git ref; the
|
|
12
|
+
// "run raw `.ts` from a dir outside node_modules" shape is what stays.
|
|
13
|
+
|
|
14
|
+
import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
// Package subtrees to copy: the TypeScript bootstrapper + engine, plus the
|
|
18
|
+
// scaffold resources `phoebe init` reads (init walks up from src/ to find them).
|
|
19
|
+
const MATERIALIZED_PARTS = ["bootstrap", "src", "templates", "prompts"];
|
|
20
|
+
|
|
21
|
+
/** Version-keyed materialization directory under `baseDir`. */
|
|
22
|
+
export function engineDir(baseDir, version) {
|
|
23
|
+
return join(baseDir, `engine-${version}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Ensure a runnable copy of the package exists outside node_modules and return
|
|
28
|
+
* the path to the bootstrapper entry (`<dir>/bootstrap/cli.ts`). Idempotent: a
|
|
29
|
+
* version-keyed marker means repeated invocations (anything that runs the bin
|
|
30
|
+
* over and over) skip the copy after the first. Callers key `baseDir` per
|
|
31
|
+
* install and the package version changes on release, so a stale copy is never
|
|
32
|
+
* reused.
|
|
33
|
+
*/
|
|
34
|
+
export function ensureEngine({ packageRoot, baseDir, version }) {
|
|
35
|
+
const dir = engineDir(baseDir, version);
|
|
36
|
+
const marker = join(dir, ".materialized");
|
|
37
|
+
if (!existsSync(marker)) {
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
for (const part of MATERIALIZED_PARTS) {
|
|
40
|
+
const from = join(packageRoot, part);
|
|
41
|
+
if (existsSync(from)) {
|
|
42
|
+
cpSync(from, join(dir, part), { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// The copied `.ts` modules must load as ESM; the nearest package.json to
|
|
46
|
+
// `<dir>/bootstrap/cli.ts` is this one. A minimal `{"type":"module"}` is
|
|
47
|
+
// enough — nothing reads its own package fields at runtime.
|
|
48
|
+
writeFileSync(join(dir, "package.json"), '{\n "type": "module"\n}\n');
|
|
49
|
+
// Write the marker last so a copy interrupted midway re-runs next time.
|
|
50
|
+
writeFileSync(marker, `${version}\n`);
|
|
51
|
+
}
|
|
52
|
+
return join(dir, "bootstrap", "cli.ts");
|
|
53
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// The reconcile watch loop — `phoebe boot`'s supervision of the running engine.
|
|
2
|
+
//
|
|
3
|
+
// `boot` is the container's long-lived main process (#40): it resolves the
|
|
4
|
+
// engine source and execs the engine as a long-running child. This module is
|
|
5
|
+
// what it does for the rest of the container's life — poll the two things that
|
|
6
|
+
// decide *which* engine should be running, and reconcile when they diverge:
|
|
7
|
+
//
|
|
8
|
+
// - the mounted `phoebe.config.ts` (its `engine` field picks the source), and
|
|
9
|
+
// - the tip of the tracked branch, versus the commit the engine is running.
|
|
10
|
+
//
|
|
11
|
+
// On a change the engine is not killed: it is sent `SIGTERM`, which it treats as
|
|
12
|
+
// a graceful drain (src/drain.ts) — finish the work unit in flight, start no new
|
|
13
|
+
// one, exit 0. Only once it is gone does boot re-resolve the source (re-read the
|
|
14
|
+
// config, fetch and check out the new ref) and spawn the replacement. So a
|
|
15
|
+
// reconcile never interrupts a work unit and never restarts the container.
|
|
16
|
+
//
|
|
17
|
+
// A poll is deliberately cheap: one `stat` of the config plus at most one
|
|
18
|
+
// `git ls-remote` (see bootstrap/github-engine.ts). No fetch, no checkout, and
|
|
19
|
+
// no work at all when nothing moved.
|
|
20
|
+
//
|
|
21
|
+
// Following a branch means eventually following it onto a commit that does not
|
|
22
|
+
// boot, so the loop also reports every finished run and asks whether a
|
|
23
|
+
// self-exit is worth retrying. The policy behind those two hooks — count fast
|
|
24
|
+
// crashes, pin back to the last-good commit — is bootstrap/crash-loop.ts; all
|
|
25
|
+
// the loop knows is that a launch may be avoiding a quarantined commit, which
|
|
26
|
+
// the ref-watch must then stop treating as a change.
|
|
27
|
+
//
|
|
28
|
+
// The loop takes everything impure as a dependency — launching, spawning, the
|
|
29
|
+
// poll clock, the stop latch — so the drain-then-relaunch ordering is unit-tested
|
|
30
|
+
// without processes or timers.
|
|
31
|
+
|
|
32
|
+
import { statSync } from "node:fs";
|
|
33
|
+
|
|
34
|
+
/** How often the watch samples the config and the tracked ref. */
|
|
35
|
+
export const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How long boot waits before relaunching an engine that crashed. Long enough
|
|
39
|
+
* that a commit dying instantly cannot spin the loop, short enough that the
|
|
40
|
+
* crash-loop guard reaches its verdict in well under a minute. Deliberately not
|
|
41
|
+
* the poll interval: a consumer who slows their reconcile poll down to a
|
|
42
|
+
* quarter-hour should not thereby wait an hour for a fallback.
|
|
43
|
+
*/
|
|
44
|
+
export const CRASH_BACKOFF_MS = 10_000;
|
|
45
|
+
|
|
46
|
+
/** Which of the two watched inputs moved, and so why the engine is relaunching. */
|
|
47
|
+
export type ReconcileReason = "config" | "ref";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A single sample of the watched world.
|
|
51
|
+
* - `config`: the mounted config's stat fingerprint; null when it cannot be read.
|
|
52
|
+
* - `remoteSha`: the tracked branch's current tip; null when there is nothing to
|
|
53
|
+
* watch — a local mount, a pinned SHA/tag, or a ref the remote does not have.
|
|
54
|
+
*/
|
|
55
|
+
export type WatchState = {
|
|
56
|
+
config: string | null;
|
|
57
|
+
remoteSha: string | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** A running engine, and the world it was launched from. */
|
|
61
|
+
export type LaunchedEngine = {
|
|
62
|
+
/** The engine CLI path that was spawned. */
|
|
63
|
+
entry: string;
|
|
64
|
+
/** The commit it is running; null for a local mount (nothing to compare). */
|
|
65
|
+
sha: string | null;
|
|
66
|
+
/** The config fingerprint at launch. */
|
|
67
|
+
config: string | null;
|
|
68
|
+
/** Sample the watched inputs now, bound to the source this engine came from. */
|
|
69
|
+
sample: () => WatchState;
|
|
70
|
+
/**
|
|
71
|
+
* The crash-looping commit this launch is avoiding, when it is a crash-loop
|
|
72
|
+
* fallback (bootstrap/crash-loop.ts) — the tracked branch still points there,
|
|
73
|
+
* and the watch must not read that as a change to chase. Null normally.
|
|
74
|
+
*/
|
|
75
|
+
quarantinedSha: string | null;
|
|
76
|
+
/**
|
|
77
|
+
* Whether the crash-loop guard covers this launch — true only for a github
|
|
78
|
+
* source tracking a moving branch. The loop only carries it through to the
|
|
79
|
+
* exit hooks; boot.ts is what decides with it.
|
|
80
|
+
*/
|
|
81
|
+
guarded: boolean;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type EngineExit = { code: number | null; signal: NodeJS.Signals | null };
|
|
85
|
+
|
|
86
|
+
/** A finished engine run: what was running, how it ended, and how long it lived. */
|
|
87
|
+
export type EngineRun = {
|
|
88
|
+
engine: LaunchedEngine;
|
|
89
|
+
exit: EngineExit;
|
|
90
|
+
elapsedMs: number;
|
|
91
|
+
/**
|
|
92
|
+
* Whether boot ended this run (a reconcile drain, or a container stop) rather
|
|
93
|
+
* than the engine exiting of its own accord — the difference between evidence
|
|
94
|
+
* about the engine commit and evidence about nothing.
|
|
95
|
+
*/
|
|
96
|
+
requestedStop: boolean;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The supervised engine child: enough of a process to drain it and await it. */
|
|
100
|
+
export type SupervisedChild = {
|
|
101
|
+
kill: (signal: NodeJS.Signals) => void;
|
|
102
|
+
exited: Promise<EngineExit>;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The outside stop request (container `SIGTERM`/`SIGINT`). A one-way latch whose
|
|
107
|
+
* `wait` doubles as the poll clock, so a shutdown wakes the loop immediately
|
|
108
|
+
* instead of sleeping out a whole poll interval — `installDrainSignal`
|
|
109
|
+
* (src/drain.ts) is exactly this shape.
|
|
110
|
+
*/
|
|
111
|
+
export type StopLatch = {
|
|
112
|
+
readonly requested: boolean;
|
|
113
|
+
wait: (ms: number) => Promise<void>;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export type SuperviseDeps = {
|
|
117
|
+
/** Re-read the config, resolve + materialize the engine source. */
|
|
118
|
+
launch: () => Promise<LaunchedEngine> | LaunchedEngine;
|
|
119
|
+
/** Spawn the engine as a long-running child. */
|
|
120
|
+
spawn: (entry: string) => SupervisedChild;
|
|
121
|
+
stop: StopLatch;
|
|
122
|
+
intervalMs?: number;
|
|
123
|
+
/** Reads the clock for run durations; injectable so the loop is tested without waiting. */
|
|
124
|
+
now?: () => number;
|
|
125
|
+
onRelaunch?: (reason: ReconcileReason) => void;
|
|
126
|
+
onLaunchError?: (error: unknown) => void;
|
|
127
|
+
onSampleError?: (error: unknown) => void;
|
|
128
|
+
/**
|
|
129
|
+
* Every finished run, however it ended — drained for a reconcile, stopped with
|
|
130
|
+
* the container, or crashed. The crash-loop guard's bookkeeping hook: this is
|
|
131
|
+
* where boot learns a commit ran healthily, or died on startup.
|
|
132
|
+
*/
|
|
133
|
+
onRunEnd?: (run: EngineRun) => void;
|
|
134
|
+
/**
|
|
135
|
+
* Each poll tick, with how long the engine has been up. The guard's other half:
|
|
136
|
+
* it banks a commit as proven while it is still running, so an engine that has
|
|
137
|
+
* been up for weeks and is then killed outright still leaves a fallback target
|
|
138
|
+
* behind.
|
|
139
|
+
*/
|
|
140
|
+
onRunTick?: (tick: { engine: LaunchedEngine; elapsedMs: number }) => void;
|
|
141
|
+
/**
|
|
142
|
+
* The engine exited on its own. True relaunches it — the crash-loop guard
|
|
143
|
+
* choosing to try again (possibly onto the last-good commit) rather than let a
|
|
144
|
+
* bad engine take the container down; false propagates the exit, which is the
|
|
145
|
+
* default and the only behaviour when no guard is wired.
|
|
146
|
+
*/
|
|
147
|
+
relaunchAfterExit?: (run: EngineRun) => boolean;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A cheap identity for the mounted config: mtime plus size. A stat is all a
|
|
152
|
+
* no-change poll should cost, and any edit — in place or a replacing rename —
|
|
153
|
+
* moves the mtime. Unreadable (missing, mid-rewrite, mount blip) is null, which
|
|
154
|
+
* `detectChange` reads as "unknown", never as a change.
|
|
155
|
+
*/
|
|
156
|
+
export function configFingerprint(
|
|
157
|
+
path: string,
|
|
158
|
+
stat: (path: string) => { mtimeMs: number; size: number } = statSync,
|
|
159
|
+
): string | null {
|
|
160
|
+
try {
|
|
161
|
+
const stats = stat(path);
|
|
162
|
+
return `${stats.mtimeMs}:${stats.size}`;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Is the running engine stale? Compares the live sample against what the engine
|
|
170
|
+
* was launched from — not against the previous sample — so the answer is always
|
|
171
|
+
* "is what's running still right", which survives a missed poll and cannot
|
|
172
|
+
* relaunch twice for the same change.
|
|
173
|
+
*
|
|
174
|
+
* Either side being null means "nothing to compare" and yields no relaunch: an
|
|
175
|
+
* unreadable config must not restart the engine, and a null `remoteSha` is how a
|
|
176
|
+
* local mount and a pinned SHA/tag report an inert ref-watch.
|
|
177
|
+
*
|
|
178
|
+
* One tip is deliberately ignored: the quarantined commit a crash-loop fallback
|
|
179
|
+
* is running away from (bootstrap/crash-loop.ts). While the branch still points
|
|
180
|
+
* there, "the running engine is not on the tip" is the intended state, not a
|
|
181
|
+
* change — reading it as one would relaunch straight back into the bad commit.
|
|
182
|
+
*/
|
|
183
|
+
export function detectChange(opts: {
|
|
184
|
+
launched: { config: string | null; sha: string | null; quarantinedSha?: string | null };
|
|
185
|
+
current: WatchState;
|
|
186
|
+
}): ReconcileReason | null {
|
|
187
|
+
const { launched, current } = opts;
|
|
188
|
+
// Config first: re-reading it can change which ref is even tracked.
|
|
189
|
+
if (launched.config !== null && current.config !== null && current.config !== launched.config) {
|
|
190
|
+
return "config";
|
|
191
|
+
}
|
|
192
|
+
if (launched.sha !== null && current.remoteSha !== null && current.remoteSha !== launched.sha) {
|
|
193
|
+
return current.remoteSha === launched.quarantinedSha ? null : "ref";
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type WatchOutcome =
|
|
199
|
+
| { kind: "exit"; exit: EngineExit }
|
|
200
|
+
| { kind: "change"; reason: ReconcileReason };
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Poll until the engine exits by itself, the container is stopping, or a watched
|
|
204
|
+
* input moves. Races the child's exit against the poll clock so neither a long
|
|
205
|
+
* poll interval nor a quiet engine delays the other.
|
|
206
|
+
*/
|
|
207
|
+
async function watchEngine(
|
|
208
|
+
engine: LaunchedEngine,
|
|
209
|
+
child: SupervisedChild,
|
|
210
|
+
deps: SuperviseDeps,
|
|
211
|
+
clock: { intervalMs: number; startedAt: number; now: () => number },
|
|
212
|
+
): Promise<WatchOutcome> {
|
|
213
|
+
const { intervalMs } = clock;
|
|
214
|
+
// One reaction on the child's exit for the whole watch — re-deriving it each
|
|
215
|
+
// iteration would pile up handlers on a promise that outlives thousands of
|
|
216
|
+
// polls over a long-lived container.
|
|
217
|
+
const exitOutcome = child.exited.then((exit) => ({ kind: "exit" as const, exit }));
|
|
218
|
+
while (true) {
|
|
219
|
+
const raced = await Promise.race([
|
|
220
|
+
exitOutcome,
|
|
221
|
+
deps.stop.wait(intervalMs).then(() => ({ kind: "tick" as const })),
|
|
222
|
+
]);
|
|
223
|
+
if (raced.kind === "exit") return raced;
|
|
224
|
+
|
|
225
|
+
if (deps.stop.requested) {
|
|
226
|
+
// The container is going down. The spawn wrapper forwards the signal to
|
|
227
|
+
// the child, but one that arrived before this child existed never reached
|
|
228
|
+
// it — so re-send (the engine's drain latch is one-way and idempotent) and
|
|
229
|
+
// wait the drain out rather than relaunching into a shutdown.
|
|
230
|
+
child.kill("SIGTERM");
|
|
231
|
+
return { kind: "exit", exit: await child.exited };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
deps.onRunTick?.({ engine, elapsedMs: clock.now() - clock.startedAt });
|
|
235
|
+
|
|
236
|
+
let current: WatchState;
|
|
237
|
+
try {
|
|
238
|
+
current = engine.sample();
|
|
239
|
+
} catch (error) {
|
|
240
|
+
// A failed poll (network blip on `ls-remote`, unreadable mount) is not a
|
|
241
|
+
// change — leave the engine alone and look again next tick.
|
|
242
|
+
deps.onSampleError?.(error);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const reason = detectChange({
|
|
247
|
+
launched: { config: engine.config, sha: engine.sha, quarantinedSha: engine.quarantinedSha },
|
|
248
|
+
current,
|
|
249
|
+
});
|
|
250
|
+
if (reason) return { kind: "change", reason };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Run the engine, and keep the *right* engine running: watch, drain, relaunch,
|
|
256
|
+
* repeat. Resolves with the exit to give the container when the engine exits on
|
|
257
|
+
* its own (and the crash-loop guard does not want it retried) or the container
|
|
258
|
+
* is stopping — the two cases where boot should stop supervising and die with
|
|
259
|
+
* the engine's status.
|
|
260
|
+
*/
|
|
261
|
+
export async function superviseEngine(deps: SuperviseDeps): Promise<EngineExit> {
|
|
262
|
+
const intervalMs = deps.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS;
|
|
263
|
+
const now = deps.now ?? Date.now;
|
|
264
|
+
let everLaunched = false;
|
|
265
|
+
|
|
266
|
+
while (true) {
|
|
267
|
+
if (deps.stop.requested) return { code: 0, signal: null };
|
|
268
|
+
|
|
269
|
+
let engine: LaunchedEngine;
|
|
270
|
+
try {
|
|
271
|
+
engine = await deps.launch();
|
|
272
|
+
} catch (error) {
|
|
273
|
+
// The first launch is the container's whole reason to exist, so a bad
|
|
274
|
+
// config or a missing mount fails loudly. A *relaunch* failure is
|
|
275
|
+
// transient by comparison (network blip mid-fetch, a ref deleted between
|
|
276
|
+
// poll and fetch) and the old engine has already drained — retrying on the
|
|
277
|
+
// next poll brings the engine back instead of taking the container down.
|
|
278
|
+
if (!everLaunched) throw error;
|
|
279
|
+
deps.onLaunchError?.(error);
|
|
280
|
+
await deps.stop.wait(intervalMs);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
everLaunched = true;
|
|
284
|
+
|
|
285
|
+
const startedAt = now();
|
|
286
|
+
const child = deps.spawn(engine.entry);
|
|
287
|
+
const outcome = await watchEngine(engine, child, deps, { intervalMs, startedAt, now });
|
|
288
|
+
|
|
289
|
+
if (outcome.kind === "exit") {
|
|
290
|
+
// A stop makes the engine's exit the container's exit, whatever the guard
|
|
291
|
+
// would have preferred — we are going down either way, and the run was cut
|
|
292
|
+
// short rather than judged.
|
|
293
|
+
const requestedStop = deps.stop.requested;
|
|
294
|
+
const run = { engine, exit: outcome.exit, elapsedMs: now() - startedAt, requestedStop };
|
|
295
|
+
deps.onRunEnd?.(run);
|
|
296
|
+
if (requestedStop || !deps.relaunchAfterExit?.(run)) return outcome.exit;
|
|
297
|
+
// Retrying a crash: back off first, so a commit that dies on startup
|
|
298
|
+
// cannot spin the loop, and re-check the stop latch the wait may have
|
|
299
|
+
// woken on. `launch` runs again from the top, which is where the fallback
|
|
300
|
+
// to a last-good commit actually takes effect.
|
|
301
|
+
await deps.stop.wait(CRASH_BACKOFF_MS);
|
|
302
|
+
if (deps.stop.requested) return outcome.exit;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
deps.onRelaunch?.(outcome.reason);
|
|
307
|
+
child.kill("SIGTERM");
|
|
308
|
+
// Nothing new starts until the drain finishes: one engine at a time, and the
|
|
309
|
+
// in-flight work unit runs to completion.
|
|
310
|
+
const exit = await child.exited;
|
|
311
|
+
deps.onRunEnd?.({ engine, exit, elapsedMs: now() - startedAt, requestedStop: true });
|
|
312
|
+
if (deps.stop.requested) return exit;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The one home for the bootstrapper's child-process signal plumbing.
|
|
2
|
+
//
|
|
3
|
+
// Both the published bin launcher (bin.mjs) and `phoebe boot` (boot.ts) exec a
|
|
4
|
+
// `node <entry>` child with inherited stdio and need the same behaviour: forward
|
|
5
|
+
// the stop signals so a container SIGTERM reaches the real process (the engine's
|
|
6
|
+
// graceful drain), then die however the child died. Keeping it in one module
|
|
7
|
+
// stops the two callers from drifting on the fiddly exit/re-raise handling.
|
|
8
|
+
//
|
|
9
|
+
// Plain JS, not TypeScript: bin.mjs runs first, still inside node_modules, where
|
|
10
|
+
// Node 24 refuses to type-strip `.ts`. boot.ts imports it untyped (same as
|
|
11
|
+
// materialize.mjs) from the materialized copy outside node_modules.
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Die however a child process died: re-raise a killing signal so this process
|
|
17
|
+
* exits the same way, or exit with the child's code.
|
|
18
|
+
*
|
|
19
|
+
* The caller must have removed its own listeners for that signal first —
|
|
20
|
+
* otherwise re-raising just re-runs them and this process falls through and
|
|
21
|
+
* exits 0, hiding the child's signal death from its parent.
|
|
22
|
+
*/
|
|
23
|
+
export function propagateExit(code, signal) {
|
|
24
|
+
if (signal) {
|
|
25
|
+
process.kill(process.pid, signal);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
process.exit(code ?? 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Spawn `node <entry> <args...>` with inherited stdio, forwarding SIGINT/SIGTERM
|
|
33
|
+
* to it, and propagate its exit (see propagateExit). Returns the child handle.
|
|
34
|
+
*
|
|
35
|
+
* `onSpawnError` overrides the default handling of a spawn failure (print
|
|
36
|
+
* `[phoebe] <message>` and exit 1); callers that want to surface it differently
|
|
37
|
+
* pass their own.
|
|
38
|
+
*
|
|
39
|
+
* `onExit` overrides dying with the child. `phoebe boot` passes one because it
|
|
40
|
+
* *supervises* the engine: a child that exited because boot drained it for a
|
|
41
|
+
* relaunch must not take the container with it (bootstrap/reconcile.ts). Callers
|
|
42
|
+
* that pass `onExit` own the propagation — propagateExit is exported for them.
|
|
43
|
+
*/
|
|
44
|
+
export function spawnEngine(entry, args, { onSpawnError, onExit } = {}) {
|
|
45
|
+
const child = spawn(process.execPath, [entry, ...args], { stdio: "inherit" });
|
|
46
|
+
|
|
47
|
+
const forwarders = new Map();
|
|
48
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
49
|
+
const forward = () => child.kill(signal);
|
|
50
|
+
forwarders.set(signal, forward);
|
|
51
|
+
process.on(signal, forward);
|
|
52
|
+
}
|
|
53
|
+
const clearForwarders = () => {
|
|
54
|
+
for (const [signal, forward] of forwarders) process.off(signal, forward);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
child.on("error", (error) => {
|
|
58
|
+
clearForwarders();
|
|
59
|
+
if (onSpawnError) {
|
|
60
|
+
onSpawnError(error);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
console.error(`[phoebe] ${error.message}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
});
|
|
66
|
+
child.on("exit", (code, signal) => {
|
|
67
|
+
// Removed before propagating: a re-raised signal must not just re-run the
|
|
68
|
+
// forwarder (a no-op kill on the dead child) and leave this process alive.
|
|
69
|
+
clearForwarders();
|
|
70
|
+
if (onExit) {
|
|
71
|
+
onExit(code, signal);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
propagateExit(code, signal);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return child;
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phoebe-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Phoebe — an AFK coding agent: a configurable engine that works a GitHub issue tracker one ticket at a time, distributed as a pinned CLI.",
|
|
6
6
|
"homepage": "https://github.com/JesusFilm/phoebe#readme",
|
|
@@ -11,43 +11,45 @@
|
|
|
11
11
|
"url": "git+https://github.com/JesusFilm/phoebe.git"
|
|
12
12
|
},
|
|
13
13
|
"bin": {
|
|
14
|
-
"phoebe": "./
|
|
15
|
-
"phoebe-agent": "./
|
|
14
|
+
"phoebe": "./bootstrap/bin.mjs",
|
|
15
|
+
"phoebe-agent": "./bootstrap/bin.mjs"
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
|
-
"
|
|
18
|
+
"bootstrap",
|
|
19
|
+
"src",
|
|
19
20
|
"prompts",
|
|
20
|
-
"templates"
|
|
21
|
+
"templates",
|
|
22
|
+
"!**/*.test.ts",
|
|
23
|
+
"!src/test-setup.ts"
|
|
21
24
|
],
|
|
22
25
|
"type": "module",
|
|
23
|
-
"main": "./
|
|
24
|
-
"types": "./
|
|
26
|
+
"main": "./bootstrap/index.mjs",
|
|
27
|
+
"types": "./bootstrap/index.ts",
|
|
25
28
|
"exports": {
|
|
26
29
|
".": {
|
|
27
|
-
"types": "./
|
|
28
|
-
"import": "./
|
|
30
|
+
"types": "./bootstrap/index.ts",
|
|
31
|
+
"import": "./bootstrap/index.mjs"
|
|
29
32
|
},
|
|
30
33
|
"./package.json": "./package.json"
|
|
31
34
|
},
|
|
32
|
-
"scripts": {
|
|
33
|
-
"ready": "vp check && vp run -r lint && vp run -r typecheck && vp run -r test && vp run -r build",
|
|
34
|
-
"check": "vp check",
|
|
35
|
-
"lint": "vp lint",
|
|
36
|
-
"typecheck": "vp exec tsc --noEmit",
|
|
37
|
-
"test": "vp test",
|
|
38
|
-
"build": "vp exec tsc -p tsconfig.build.json",
|
|
39
|
-
"changeset": "changeset",
|
|
40
|
-
"version-packages": "changeset version",
|
|
41
|
-
"release": "vp run build && changeset publish"
|
|
42
|
-
},
|
|
43
35
|
"devDependencies": {
|
|
44
36
|
"@changesets/cli": "^2.31.1",
|
|
45
|
-
"@types/node": "^
|
|
37
|
+
"@types/node": "^24.0.0",
|
|
46
38
|
"typescript": "^5.7.0",
|
|
47
39
|
"vite-plus": "^0.2.0"
|
|
48
40
|
},
|
|
49
41
|
"engines": {
|
|
50
|
-
"node": ">=
|
|
42
|
+
"node": ">=24"
|
|
51
43
|
},
|
|
52
|
-
"
|
|
53
|
-
|
|
44
|
+
"scripts": {
|
|
45
|
+
"ready": "vp check && vp run -r lint && vp run -r typecheck && vp run -r test",
|
|
46
|
+
"check": "vp check",
|
|
47
|
+
"lint": "vp lint",
|
|
48
|
+
"typecheck": "vp exec tsc --noEmit",
|
|
49
|
+
"test": "vp test",
|
|
50
|
+
"phoebe": "bash .phoebe/run.sh",
|
|
51
|
+
"changeset": "changeset",
|
|
52
|
+
"version-packages": "changeset version",
|
|
53
|
+
"release": "changeset publish"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/prompts/checks-prompt.md
CHANGED
|
@@ -24,17 +24,31 @@ You are Phoebe — fixing **failing CI** on an existing PR branch in this reposi
|
|
|
24
24
|
gh run view <run-id> --log-failed
|
|
25
25
|
```
|
|
26
26
|
2. **Reproduce** — run the same gates locally: `{{CHECK_COMMAND}}` and `{{TEST_COMMAND}}` (or `{{READY_COMMAND}}` if the project ships an all-in-one gate).
|
|
27
|
-
3. **
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
3. **Baseline check** — before assuming a local `{{TEST_COMMAND}}` failure is yours, confirm whether it also fails on a clean `{{DEFAULT_BRANCH}}`. This distinguishes breakage _caused by this PR_ from **pre-existing baseline breakage** already red on `{{DEFAULT_BRANCH}}`.
|
|
28
|
+
- Establish a clean baseline without disturbing this branch, e.g.:
|
|
29
|
+
```
|
|
30
|
+
git worktree add /tmp/baseline origin/{{DEFAULT_BRANCH}}
|
|
31
|
+
```
|
|
32
|
+
then install (`{{INSTALL_COMMAND}}`) and run `{{TEST_COMMAND}}` inside `/tmp/baseline`. Remove it with `git worktree remove /tmp/baseline` when done.
|
|
33
|
+
- **Same failures present on the baseline → pre-existing and out of scope for this PR.** Do **not** try to fix them here.
|
|
34
|
+
- Proceed as long as _this change's own_ gate is green: `{{CHECK_COMMAND}}` passes and any tests newly relevant to your change pass.
|
|
35
|
+
- Leave a short PR comment noting the pre-existing failures. If no tracking issue already covers them, open one and link it:
|
|
36
|
+
```
|
|
37
|
+
gh issue create --title "<baseline test failure>" --body "<what fails on {{DEFAULT_BRANCH}}, and where you saw it>"
|
|
38
|
+
```
|
|
39
|
+
- **Failures absent from the baseline → attributable to this change.** These are yours; fix them in the next step.
|
|
40
|
+
- **Reconciling disagreeing gates:** a green `{{CHECK_COMMAND}}` with a red `{{TEST_COMMAND}}` clears you to proceed **only** when every red test is baseline-only. If any failing test is _not_ present on the baseline, `{{CHECK_COMMAND}}` passing does not excuse it — fix that test.
|
|
41
|
+
4. **Fix** — make the smallest correct change that resolves the failures **attributable to this change** (those not present on the baseline).
|
|
42
|
+
5. **Verify** — re-run `{{CHECK_COMMAND}}` and `{{TEST_COMMAND}}` and fix any remaining failures that are not baseline-only.
|
|
43
|
+
6. **Commit** — one or more commits with the `Phoebe:` prefix. Do **not** force-push.
|
|
44
|
+
7. **Push** — `git push origin {{PR_BRANCH}}`.
|
|
45
|
+
8. **Flaky escape hatch** — if the failure does not reproduce locally and looks environmental or flaky, you may instead:
|
|
32
46
|
- `gh run rerun --failed` (once)
|
|
33
47
|
- Comment on the PR explaining why no code change was made:
|
|
34
48
|
```
|
|
35
49
|
gh pr comment {{PR_NUMBER}} --body "<explanation>"
|
|
36
50
|
```
|
|
37
|
-
|
|
51
|
+
9. **Give up** — only if you cannot fix or rerun, leave a PR comment explaining what you tried:
|
|
38
52
|
```
|
|
39
53
|
gh pr comment {{PR_NUMBER}} --body "<explanation>"
|
|
40
54
|
```
|
|
@@ -42,6 +56,7 @@ You are Phoebe — fixing **failing CI** on an existing PR branch in this reposi
|
|
|
42
56
|
## Rules
|
|
43
57
|
|
|
44
58
|
- Work on **this PR only** (#{{PR_NUMBER}}). Do not pick up issues or other PRs.
|
|
59
|
+
- Only failures **caused by this change** are yours to fix. Pre-existing failures already red on `{{DEFAULT_BRANCH}}` are out of scope — note and track them, don't fix them here.
|
|
45
60
|
- Do not leave commented-out code or TODO comments in committed code.
|
|
46
61
|
- Never force-push (`git push --force`).
|
|
47
62
|
- If you are blocked, comment on the PR and finish — do not push a broken fix.
|
|
@@ -28,7 +28,17 @@ When `{{BLOCKER_PR_NUMBERS}}` is empty, merge `origin/{{DEFAULT_BRANCH}}` only (
|
|
|
28
28
|
|
|
29
29
|
1. **Assess** — run `git status`. If no merge is in progress, run the merge order above from scratch.
|
|
30
30
|
2. **Resolve** — fix every conflicted file. Prefer preserving both sides' intent; do not drop unrelated changes from `{{DEFAULT_BRANCH}}` or this branch.
|
|
31
|
-
3. **Verify** — run `{{CHECK_COMMAND}}` and `{{TEST_COMMAND}}` (or `{{READY_COMMAND}}` if the project ships an all-in-one gate). Fix any failures before proceeding.
|
|
31
|
+
3. **Verify** — run `{{CHECK_COMMAND}}` and `{{TEST_COMMAND}}` (or `{{READY_COMMAND}}` if the project ships an all-in-one gate). Fix any failures the merge introduced before proceeding.
|
|
32
|
+
- **Baseline breakage:** if `{{TEST_COMMAND}}` fails, confirm whether the same failures already exist on a clean `{{DEFAULT_BRANCH}}` checkout before assuming the merge caused them:
|
|
33
|
+
```
|
|
34
|
+
git worktree add /tmp/baseline origin/{{DEFAULT_BRANCH}}
|
|
35
|
+
```
|
|
36
|
+
install (`{{INSTALL_COMMAND}}`) and run `{{TEST_COMMAND}}` there, then `git worktree remove /tmp/baseline`.
|
|
37
|
+
- Failures **present on the baseline** are pre-existing and out of scope for this reconciliation — do **not** fix them here. Proceed once the merge's own gate is green (`{{CHECK_COMMAND}}` passes and the merge introduced no _new_ test failures), then note the pre-existing failures in a PR comment and, if no tracking issue covers them, open one:
|
|
38
|
+
```
|
|
39
|
+
gh issue create --title "<baseline test failure>" --body "<what fails on {{DEFAULT_BRANCH}}, and where you saw it>"
|
|
40
|
+
```
|
|
41
|
+
- A green `{{CHECK_COMMAND}}` with a red `{{TEST_COMMAND}}` clears you only when every red test is baseline-only. Any failure the **merge** introduced must be fixed before you push.
|
|
32
42
|
4. **Commit** — stage the resolution and commit each merge (e.g. `Phoebe: merge blocker PR #N into {{PR_BRANCH}}`, then `Phoebe: merge {{DEFAULT_BRANCH}} into {{PR_BRANCH}}`). Do **not** force-push.
|
|
33
43
|
5. **Push** — `git push origin {{PR_BRANCH}}`.
|
|
34
44
|
6. **Comment** — only if you **cannot** resolve cleanly or tests still fail after resolving:
|
|
@@ -42,6 +52,7 @@ When `{{BLOCKER_PR_NUMBERS}}` is empty, merge `origin/{{DEFAULT_BRANCH}}` only (
|
|
|
42
52
|
## Rules
|
|
43
53
|
|
|
44
54
|
- Work on **this PR only** (#{{PR_NUMBER}}). Do not pick up issues or other PRs.
|
|
55
|
+
- Only failures the **merge** introduced are yours to fix. Pre-existing failures already red on `{{DEFAULT_BRANCH}}` are out of scope — note and track them, don't fix them here.
|
|
45
56
|
- Do not leave commented-out code or TODO comments in committed code.
|
|
46
57
|
- Never force-push (`git push --force`).
|
|
47
58
|
- If you are blocked, abort the merge, comment on the PR, and finish — do not leave the branch in a conflicted state.
|