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.
- package/README.md +77 -8
- package/bootstrap/bin.mjs +42 -0
- package/bootstrap/boot.ts +431 -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 +145 -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 +17 -5
- package/templates/container/Dockerfile +128 -19
- package/templates/container/compose.local.yml +37 -0
- package/templates/container/compose.yml +43 -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,391 @@
|
|
|
1
|
+
// Crash-loop fallback for `phoebe boot` — the guard on a bad engine ref.
|
|
2
|
+
//
|
|
3
|
+
// The reconcile watch (reconcile.ts) will happily follow the tracked branch onto
|
|
4
|
+
// any commit someone pushes, including one that dies on startup. Without a guard
|
|
5
|
+
// that is an unattended container crash-looping on an unattended repo. So boot
|
|
6
|
+
// keeps a small record of which engine commits actually *ran*: a SHA that dies
|
|
7
|
+
// fast, repeatedly, is quarantined, and boot pins back to the last SHA that ran
|
|
8
|
+
// healthily until the tracked branch moves past the bad one.
|
|
9
|
+
//
|
|
10
|
+
// This module is that record and the decisions over it — pure folds plus a JSON
|
|
11
|
+
// file — so the whole ladder (first crash → threshold → fallback → recovery) is
|
|
12
|
+
// tested without spawning an engine. The wiring (when a run ends, what to
|
|
13
|
+
// materialize, what to log) lives in boot.ts; the loop that calls it is
|
|
14
|
+
// reconcile.ts.
|
|
15
|
+
//
|
|
16
|
+
// The record lives in the engine's state dir (`paths.stateDir`, default
|
|
17
|
+
// `/data/state` — a named volume) rather than beside the engine clone, so a
|
|
18
|
+
// quarantine survives a container restart *and* a wiped engine volume: the whole
|
|
19
|
+
// point is to remember across the restart the crash itself causes.
|
|
20
|
+
//
|
|
21
|
+
// Only a github source with a moving branch is guarded. A local mount has no
|
|
22
|
+
// commit to pin, and a pinned ref means pinning — boot must not silently serve
|
|
23
|
+
// different code than the operator asked for (see boot.ts's eligibility check).
|
|
24
|
+
//
|
|
25
|
+
// This is the only crash-loop policy there is: the engine's own self-update and
|
|
26
|
+
// the shell supervisor that mirrored a copy of these decisions are gone (#44).
|
|
27
|
+
|
|
28
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Consecutive fast crashes of one engine SHA before boot pins back to the
|
|
33
|
+
* last-good one. Three is enough to rule out a one-off (a flaky network on the
|
|
34
|
+
* engine's first fetch, a busy host) while still recovering in minutes.
|
|
35
|
+
*/
|
|
36
|
+
export const CRASH_LOOP_THRESHOLD = 3;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How long an engine run must survive to count as healthy. A commit that cannot
|
|
40
|
+
* boot dies well inside this; a long-lived engine that later hits a runtime
|
|
41
|
+
* error does not, and pinning to older code would not help it.
|
|
42
|
+
*/
|
|
43
|
+
export const HEALTHY_RUN_MS = 60_000;
|
|
44
|
+
|
|
45
|
+
/** State dir used when the mounted config names none (matches the engine's default). */
|
|
46
|
+
export const DEFAULT_STATE_DIR = "/data/state";
|
|
47
|
+
|
|
48
|
+
/** Filename of the crash-loop record inside the state dir. */
|
|
49
|
+
export const CRASH_LOOP_STATE_FILE = "engine-crash-loop.json";
|
|
50
|
+
|
|
51
|
+
/** Crash-loop bookkeeping, persisted across container restarts on the state volume. */
|
|
52
|
+
export type CrashLoopState = {
|
|
53
|
+
/** SHA that last ran healthily — the fallback target. */
|
|
54
|
+
lastGoodSha: string | null;
|
|
55
|
+
/** SHA currently accumulating fast-crash counts (or quarantined as bad). */
|
|
56
|
+
failingSha: string | null;
|
|
57
|
+
/** Consecutive fast crashes recorded for `failingSha`. */
|
|
58
|
+
failureCount: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Nothing known yet: no proven commit, no quarantine. */
|
|
62
|
+
export const INITIAL_CRASH_LOOP_STATE: CrashLoopState = {
|
|
63
|
+
lastGoodSha: null,
|
|
64
|
+
failingSha: null,
|
|
65
|
+
failureCount: 0,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A finished engine run, as the fallback policy sees it. */
|
|
69
|
+
export type RunOutcome = {
|
|
70
|
+
/** The engine commit that was running. */
|
|
71
|
+
sha: string;
|
|
72
|
+
/** Its exit code; null when a signal killed it. */
|
|
73
|
+
exitCode: number | null;
|
|
74
|
+
/** How long it lived. */
|
|
75
|
+
elapsedMs: number;
|
|
76
|
+
/**
|
|
77
|
+
* Whether boot ended this run — a reconcile drain or a container stop — rather
|
|
78
|
+
* than the engine exiting of its own accord. Load-bearing: a run boot cut
|
|
79
|
+
* short proves nothing either way.
|
|
80
|
+
*/
|
|
81
|
+
requestedStop: boolean;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** What a finished run proved about the commit it was running. */
|
|
85
|
+
export type RunVerdict =
|
|
86
|
+
/** The commit works — it lived past the healthy window, or finished its own work. */
|
|
87
|
+
| "healthy"
|
|
88
|
+
/** The commit dies on startup: the thing the fallback exists to catch. */
|
|
89
|
+
| "crash"
|
|
90
|
+
/** Nothing was proved, so the record must not move. */
|
|
91
|
+
| "inconclusive";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Judge a finished run. The three-way answer matters: a run that neither proved
|
|
95
|
+
* nor disproved its commit has to leave the record alone, and reading it as
|
|
96
|
+
* "healthy" would be the worse mistake — a container stop landing mid-crash-loop
|
|
97
|
+
* would promote the crashing commit to last-good and disarm the fallback for
|
|
98
|
+
* good.
|
|
99
|
+
*/
|
|
100
|
+
export function judgeRun(run: RunOutcome, healthyMs: number = HEALTHY_RUN_MS): RunVerdict {
|
|
101
|
+
// Living past the window proves the commit boots, however the run then ended.
|
|
102
|
+
if (run.elapsedMs >= healthyMs) return "healthy";
|
|
103
|
+
// Boot pulled the plug before the window was up: no verdict either way.
|
|
104
|
+
if (run.requestedStop) return "inconclusive";
|
|
105
|
+
// Exiting 0 unprompted means the engine finished what it was asked to do
|
|
106
|
+
// (`--run-once`), which it could only do by booting.
|
|
107
|
+
if (run.exitCode === 0) return "healthy";
|
|
108
|
+
// A signal from outside — an OOM kill, a `docker kill` — says nothing about
|
|
109
|
+
// the code either.
|
|
110
|
+
if (run.exitCode === null) return "inconclusive";
|
|
111
|
+
return "crash";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fold a finished run into the record. A healthy run becomes the new last-good,
|
|
116
|
+
* but a quarantine on a *different* SHA is preserved — the healthy run is
|
|
117
|
+
* usually the fallback itself, and clearing the record there would send the next
|
|
118
|
+
* launch straight back into the commit it is avoiding. A fast crash counts
|
|
119
|
+
* against its own SHA, starting over whenever the failing SHA moves — except
|
|
120
|
+
* against an *active* quarantine, which outlives it.
|
|
121
|
+
*/
|
|
122
|
+
export function recordRun(
|
|
123
|
+
state: CrashLoopState,
|
|
124
|
+
run: RunOutcome,
|
|
125
|
+
opts: { healthyMs?: number; threshold?: number } = {},
|
|
126
|
+
): CrashLoopState {
|
|
127
|
+
const threshold = opts.threshold ?? CRASH_LOOP_THRESHOLD;
|
|
128
|
+
switch (judgeRun(run, opts.healthyMs ?? HEALTHY_RUN_MS)) {
|
|
129
|
+
case "inconclusive":
|
|
130
|
+
return state;
|
|
131
|
+
case "healthy": {
|
|
132
|
+
const stillQuarantining = state.failingSha !== null && state.failingSha !== run.sha;
|
|
133
|
+
return {
|
|
134
|
+
lastGoodSha: run.sha,
|
|
135
|
+
failingSha: stillQuarantining ? state.failingSha : null,
|
|
136
|
+
failureCount: stillQuarantining ? state.failureCount : 0,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
case "crash": {
|
|
140
|
+
if (state.failingSha === run.sha) return { ...state, failureCount: state.failureCount + 1 };
|
|
141
|
+
// The fallback crashing too does not exonerate the commit it is standing
|
|
142
|
+
// in for: the quarantine has to hold until the tracked ref moves past the
|
|
143
|
+
// bad SHA, so a crash of some other commit must not overwrite it.
|
|
144
|
+
if (state.failingSha !== null && state.failureCount >= threshold) return state;
|
|
145
|
+
return { lastGoodSha: state.lastGoodSha, failingSha: run.sha, failureCount: 1 };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Is there a better commit to relaunch onto than the one that just crashed?
|
|
152
|
+
* Answers boot's question after a fast crash: retry (the fallback is reachable,
|
|
153
|
+
* even if the threshold is not met yet) or give up and let the container exit.
|
|
154
|
+
* Without this, a first-ever bad ref — or a fallback that crashes too — would
|
|
155
|
+
* relaunch forever instead of failing where an operator can see it.
|
|
156
|
+
*/
|
|
157
|
+
export function hasFallbackTarget(state: CrashLoopState, crashedSha: string): boolean {
|
|
158
|
+
return state.lastGoodSha !== null && state.lastGoodSha !== crashedSha;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The commit to run *instead of* `targetSha` (the tracked ref's current tip), or
|
|
163
|
+
* null to run the target as normal. Non-null only once the target has
|
|
164
|
+
* fast-crashed `threshold` times and a different known-good commit exists — so
|
|
165
|
+
* the fallback lapses by itself the moment the branch advances past the bad SHA,
|
|
166
|
+
* which is exactly "fallback persists until the ref moves on".
|
|
167
|
+
*/
|
|
168
|
+
export function fallbackSha(
|
|
169
|
+
targetSha: string,
|
|
170
|
+
state: CrashLoopState,
|
|
171
|
+
threshold: number = CRASH_LOOP_THRESHOLD,
|
|
172
|
+
): string | null {
|
|
173
|
+
if (state.failingSha !== targetSha || state.failureCount < threshold) return null;
|
|
174
|
+
return hasFallbackTarget(state, targetSha) ? state.lastGoodSha : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Where the engine keeps its persistent state, read straight from the mounted
|
|
179
|
+
* config. The bootstrapper resolves this itself — it writes the record before
|
|
180
|
+
* the engine (which owns the config schema) has even been materialized — so a
|
|
181
|
+
* missing or malformed `paths.stateDir` falls back to the engine's own default
|
|
182
|
+
* rather than being trusted or rejected.
|
|
183
|
+
*/
|
|
184
|
+
export function readStateDir(config: Record<string, unknown>): string {
|
|
185
|
+
const paths = config["paths"];
|
|
186
|
+
if (paths === null || typeof paths !== "object") return DEFAULT_STATE_DIR;
|
|
187
|
+
const stateDir = (paths as Record<string, unknown>)["stateDir"];
|
|
188
|
+
return typeof stateDir === "string" && stateDir.length > 0 ? stateDir : DEFAULT_STATE_DIR;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The crash-loop record's path inside a state dir. */
|
|
192
|
+
export function crashLoopStatePath(stateDir: string): string {
|
|
193
|
+
return join(stateDir, CRASH_LOOP_STATE_FILE);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isCrashLoopState(value: unknown): value is CrashLoopState {
|
|
197
|
+
if (value === null || typeof value !== "object") return false;
|
|
198
|
+
const state = value as Record<string, unknown>;
|
|
199
|
+
return (
|
|
200
|
+
(state["lastGoodSha"] === null || typeof state["lastGoodSha"] === "string") &&
|
|
201
|
+
(state["failingSha"] === null || typeof state["failingSha"] === "string") &&
|
|
202
|
+
typeof state["failureCount"] === "number"
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read the record, or start fresh. Anything unreadable — no file yet, a
|
|
208
|
+
* half-written one from a container killed mid-write, a hand-edited mess —
|
|
209
|
+
* degrades to "nothing known" rather than failing boot: the cost is losing one
|
|
210
|
+
* fallback target, and the alternative is a bootstrapper that a bad file bricks.
|
|
211
|
+
*/
|
|
212
|
+
export function readCrashLoopState(path: string): CrashLoopState {
|
|
213
|
+
try {
|
|
214
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
215
|
+
if (!isCrashLoopState(parsed)) return INITIAL_CRASH_LOOP_STATE;
|
|
216
|
+
return {
|
|
217
|
+
lastGoodSha: parsed.lastGoodSha,
|
|
218
|
+
failingSha: parsed.failingSha,
|
|
219
|
+
failureCount: parsed.failureCount,
|
|
220
|
+
};
|
|
221
|
+
} catch {
|
|
222
|
+
return INITIAL_CRASH_LOOP_STATE;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Persist the record, creating the state dir if the volume is empty. Throws on a
|
|
228
|
+
* genuinely unwritable state dir; the guard turns that into an event, since
|
|
229
|
+
* losing the fallback is better than refusing to run the engine.
|
|
230
|
+
*/
|
|
231
|
+
export function writeCrashLoopState(path: string, state: CrashLoopState): void {
|
|
232
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
233
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Everything the guard decides that is worth telling an operator about. Emitted
|
|
238
|
+
* rather than logged so the policy stays testable and boot.ts owns the wording —
|
|
239
|
+
* a container silently serving older code than its config asks for is exactly
|
|
240
|
+
* the confusing situation these lines exist to prevent.
|
|
241
|
+
*/
|
|
242
|
+
export type CrashGuardEvent =
|
|
243
|
+
| {
|
|
244
|
+
kind: "crash";
|
|
245
|
+
sha: string;
|
|
246
|
+
exitCode: number | null;
|
|
247
|
+
elapsedMs: number;
|
|
248
|
+
failureCount: number;
|
|
249
|
+
threshold: number;
|
|
250
|
+
}
|
|
251
|
+
/** A run proved its commit; it is now the fallback target. */
|
|
252
|
+
| { kind: "last-good"; sha: string }
|
|
253
|
+
/** This launch is pinned to `lastGoodSha` instead of the tracked ref's tip. */
|
|
254
|
+
| { kind: "fallback"; quarantinedSha: string; lastGoodSha: string; failureCount: number }
|
|
255
|
+
/** The fallback crashed too. The quarantine holds; boot is out of options. */
|
|
256
|
+
| {
|
|
257
|
+
kind: "fallback-crashed";
|
|
258
|
+
sha: string;
|
|
259
|
+
quarantinedSha: string;
|
|
260
|
+
exitCode: number | null;
|
|
261
|
+
elapsedMs: number;
|
|
262
|
+
}
|
|
263
|
+
/** The tracked ref moved past the quarantined commit; the pin lapses. */
|
|
264
|
+
| { kind: "recovered"; quarantinedSha: string; sha: string }
|
|
265
|
+
| { kind: "persist-failed"; path: string; error: unknown };
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The crash-loop guard boot supervises with: the persisted record, held in
|
|
269
|
+
* memory for the life of the container and written through on every change.
|
|
270
|
+
*/
|
|
271
|
+
export type CrashGuard = {
|
|
272
|
+
/**
|
|
273
|
+
* The commit to run instead of `targetSha` (the tracked ref's tip), or null to
|
|
274
|
+
* run the target. Called once per launch, before the engine is spawned.
|
|
275
|
+
*/
|
|
276
|
+
fallbackFor: (targetSha: string) => string | null;
|
|
277
|
+
/** Fold a finished run into the record and persist it. */
|
|
278
|
+
record: (run: RunOutcome) => void;
|
|
279
|
+
/**
|
|
280
|
+
* A commit is still running, and has been for `elapsedMs`. Once that passes
|
|
281
|
+
* the healthy window the commit has proved itself, so it is banked as the
|
|
282
|
+
* fallback target *while it runs* — an engine that has been up for a month and
|
|
283
|
+
* is then killed by a host reboot would otherwise have left no record at all,
|
|
284
|
+
* and the bad commit waiting on the branch would have nothing to fall back to.
|
|
285
|
+
*/
|
|
286
|
+
noteAlive: (sha: string, elapsedMs: number) => void;
|
|
287
|
+
/**
|
|
288
|
+
* Is relaunching after this run worth it? Only for a crash, and only when a
|
|
289
|
+
* *different* known-good commit exists to end up on — otherwise boot lets the
|
|
290
|
+
* container exit rather than loop on the same broken commit forever. The
|
|
291
|
+
* answer does not depend on whether `record` ran first: folding a crash in
|
|
292
|
+
* never changes which commit is last-good.
|
|
293
|
+
*/
|
|
294
|
+
shouldRetry: (run: RunOutcome) => boolean;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
export function createCrashGuard(deps: {
|
|
298
|
+
/** The record's path, resolved from the mounted config at boot. */
|
|
299
|
+
statePath: string;
|
|
300
|
+
onEvent?: (event: CrashGuardEvent) => void;
|
|
301
|
+
threshold?: number;
|
|
302
|
+
healthyMs?: number;
|
|
303
|
+
}): CrashGuard {
|
|
304
|
+
const threshold = deps.threshold ?? CRASH_LOOP_THRESHOLD;
|
|
305
|
+
const healthyMs = deps.healthyMs ?? HEALTHY_RUN_MS;
|
|
306
|
+
const options = { threshold, healthyMs };
|
|
307
|
+
const emit = (event: CrashGuardEvent) => deps.onEvent?.(event);
|
|
308
|
+
let state = readCrashLoopState(deps.statePath);
|
|
309
|
+
|
|
310
|
+
const persist = (next: CrashLoopState) => {
|
|
311
|
+
state = next;
|
|
312
|
+
try {
|
|
313
|
+
writeCrashLoopState(deps.statePath, next);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
// In-memory bookkeeping still guards this container's life; only the
|
|
316
|
+
// across-restart half is lost.
|
|
317
|
+
emit({ kind: "persist-failed", path: deps.statePath, error });
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
fallbackFor(targetSha) {
|
|
323
|
+
const pin = fallbackSha(targetSha, state, threshold);
|
|
324
|
+
if (pin !== null) {
|
|
325
|
+
emit({
|
|
326
|
+
kind: "fallback",
|
|
327
|
+
quarantinedSha: targetSha,
|
|
328
|
+
lastGoodSha: pin,
|
|
329
|
+
failureCount: state.failureCount,
|
|
330
|
+
});
|
|
331
|
+
return pin;
|
|
332
|
+
}
|
|
333
|
+
if (state.failingSha !== null && state.failingSha !== targetSha) {
|
|
334
|
+
// The tracked ref has moved on, so whatever we knew about the old commit
|
|
335
|
+
// is history. Dropping it here (rather than leaving it for the next
|
|
336
|
+
// crash to overwrite) is what makes the fallback lapse exactly once.
|
|
337
|
+
const quarantinedSha = state.failingSha;
|
|
338
|
+
const wasQuarantined = state.failureCount >= threshold;
|
|
339
|
+
persist({ lastGoodSha: state.lastGoodSha, failingSha: null, failureCount: 0 });
|
|
340
|
+
if (wasQuarantined) emit({ kind: "recovered", quarantinedSha, sha: targetSha });
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
record(run) {
|
|
346
|
+
const before = state;
|
|
347
|
+
const verdict = judgeRun(run, healthyMs);
|
|
348
|
+
persist(recordRun(before, run, options));
|
|
349
|
+
|
|
350
|
+
if (verdict === "inconclusive") return;
|
|
351
|
+
if (verdict === "healthy") {
|
|
352
|
+
if (state.lastGoodSha !== before.lastGoodSha) emit({ kind: "last-good", sha: run.sha });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (
|
|
356
|
+
before.failingSha !== null &&
|
|
357
|
+
before.failingSha !== run.sha &&
|
|
358
|
+
before.failureCount >= threshold
|
|
359
|
+
) {
|
|
360
|
+
// A crash the quarantine swallowed — this is the fallback itself dying,
|
|
361
|
+
// which reads very differently in a log to the tracked ref dying.
|
|
362
|
+
emit({
|
|
363
|
+
kind: "fallback-crashed",
|
|
364
|
+
sha: run.sha,
|
|
365
|
+
quarantinedSha: before.failingSha,
|
|
366
|
+
exitCode: run.exitCode,
|
|
367
|
+
elapsedMs: run.elapsedMs,
|
|
368
|
+
});
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
emit({
|
|
372
|
+
kind: "crash",
|
|
373
|
+
sha: run.sha,
|
|
374
|
+
exitCode: run.exitCode,
|
|
375
|
+
elapsedMs: run.elapsedMs,
|
|
376
|
+
failureCount: state.failureCount,
|
|
377
|
+
threshold,
|
|
378
|
+
});
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
noteAlive(sha, elapsedMs) {
|
|
382
|
+
if (elapsedMs < healthyMs || state.lastGoodSha === sha) return;
|
|
383
|
+
persist(recordRun(state, { sha, exitCode: null, elapsedMs, requestedStop: false }, options));
|
|
384
|
+
emit({ kind: "last-good", sha });
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
shouldRetry(run) {
|
|
388
|
+
return judgeRun(run, healthyMs) === "crash" && hasFallbackTarget(state, run.sha);
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// `defineConfig` — the identity typing helper consumers import from
|
|
2
|
+
// `phoebe-agent` to author their `phoebe.config.ts`:
|
|
3
|
+
//
|
|
4
|
+
// ```ts
|
|
5
|
+
// import { defineConfig } from "phoebe-agent";
|
|
6
|
+
// export default defineConfig({ repoSlug: "...", ... });
|
|
7
|
+
// ```
|
|
8
|
+
//
|
|
9
|
+
// It lives in the bootstrapper (the published package surface) rather than the
|
|
10
|
+
// engine because the bootstrapper is what a consumer installs. The config shape
|
|
11
|
+
// itself (`PhoebeUserConfig`, including the bootstrapper-only `engine` field) is
|
|
12
|
+
// owned by the engine's config schema; this is only the typing helper. The
|
|
13
|
+
// value is never read at runtime beyond being forwarded — the whole benefit is
|
|
14
|
+
// editor autocomplete and a compile-time check that only known fields appear.
|
|
15
|
+
|
|
16
|
+
import type { PhoebeUserConfig } from "../src/config-schema.ts";
|
|
17
|
+
|
|
18
|
+
export function defineConfig(config: PhoebeUserConfig): PhoebeUserConfig {
|
|
19
|
+
return config;
|
|
20
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The bootstrapper's minimal engine-source reader.
|
|
2
|
+
//
|
|
3
|
+
// The published `phoebe-agent` package is a thin bootstrapper: it materializes
|
|
4
|
+
// the engine (`src/`) from a git ref or a local mount, then runs it. The only
|
|
5
|
+
// part of the consumer's mounted `phoebe.config.ts` the bootstrapper needs is
|
|
6
|
+
// the `engine` field — the full config schema stays the engine's concern
|
|
7
|
+
// (config-schema.ts + resolveConfig). This module is that minimal reader: it
|
|
8
|
+
// takes the `engine` field (or a loaded config carrying it) and returns the
|
|
9
|
+
// resolved source with defaults applied, and nothing else.
|
|
10
|
+
//
|
|
11
|
+
// The user-facing field type (`EngineSourceField`) lives with the rest of the
|
|
12
|
+
// config schema in the engine so there is one source of truth for the config
|
|
13
|
+
// shape; this module owns only the resolution + the defaults.
|
|
14
|
+
|
|
15
|
+
import type { EngineSourceField } from "../src/config-schema.ts";
|
|
16
|
+
|
|
17
|
+
/** Repo the engine is cloned from when `source: "github"` omits `repo`. */
|
|
18
|
+
export const DEFAULT_ENGINE_REPO = "JesusFilm/phoebe";
|
|
19
|
+
/** Ref the engine is checked out at when `source: "github"` omits `ref`. */
|
|
20
|
+
export const DEFAULT_ENGINE_REF = "main";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The engine source with every default applied — what the bootstrapper acts on.
|
|
24
|
+
* `github` always carries a concrete `ref` and `repo`; `local` carries nothing
|
|
25
|
+
* (the engine is read from its mount).
|
|
26
|
+
*/
|
|
27
|
+
export type ResolvedEngineSource =
|
|
28
|
+
| { source: "github"; ref: string; repo: string }
|
|
29
|
+
| { source: "local" };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the optional `engine` field into a concrete source. An omitted field
|
|
33
|
+
* (or an omitted `ref`/`repo` on a github source) falls back to the shipped
|
|
34
|
+
* defaults: github, ref `main`, repo `JesusFilm/phoebe`.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveEngineSource(field: EngineSourceField | undefined): ResolvedEngineSource {
|
|
37
|
+
if (field === undefined || field.source === "github") {
|
|
38
|
+
return {
|
|
39
|
+
source: "github",
|
|
40
|
+
ref: field?.ref ?? DEFAULT_ENGINE_REF,
|
|
41
|
+
repo: field?.repo ?? DEFAULT_ENGINE_REPO,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { source: "local" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether an arbitrary value is a well-formed `engine` field. The bootstrapper
|
|
49
|
+
* is the only reader of `engine` — the engine drops it (`resolveConfig`) rather
|
|
50
|
+
* than validating it — so a malformed value has to be rejected here or it never
|
|
51
|
+
* is. An unknown `source` must not fall through to `local`, and a non-string
|
|
52
|
+
* `ref`/`repo` must not escape into a `ResolvedEngineSource` typed as strings.
|
|
53
|
+
*/
|
|
54
|
+
function isEngineSourceField(value: unknown): value is EngineSourceField {
|
|
55
|
+
if (value === null || typeof value !== "object") return false;
|
|
56
|
+
const field = value as Record<string, unknown>;
|
|
57
|
+
if (field["source"] === "local") return true;
|
|
58
|
+
return (
|
|
59
|
+
field["source"] === "github" &&
|
|
60
|
+
(field["ref"] === undefined || typeof field["ref"] === "string") &&
|
|
61
|
+
(field["repo"] === undefined || typeof field["repo"] === "string")
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract the resolved engine source from a loaded consumer config, ignoring
|
|
67
|
+
* every other field. This is the whole of the bootstrapper's interest in the
|
|
68
|
+
* config; the engine reads (and validates) the rest once it is materialized and
|
|
69
|
+
* run. The config arrives as a dynamically-imported module value, so the param
|
|
70
|
+
* is an arbitrary record — `engine`, if present, is validated before use so a
|
|
71
|
+
* typo (bad `source`, numeric `ref`/`repo`) fails loudly instead of silently
|
|
72
|
+
* resolving to the wrong source.
|
|
73
|
+
*/
|
|
74
|
+
export function readEngineSource(config: Record<string, unknown>): ResolvedEngineSource {
|
|
75
|
+
const field = config["engine"];
|
|
76
|
+
if (field !== undefined && !isEngineSourceField(field)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`phoebe.config.ts \`engine\` must be { source: "github", ref?, repo? } or ` +
|
|
79
|
+
`{ source: "local" } with string ref/repo (got ${JSON.stringify(field)}).`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return resolveEngineSource(field);
|
|
83
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Materialize the Phoebe engine from GitHub for `engine: { source: "github" }`.
|
|
2
|
+
//
|
|
3
|
+
// The published package is a thin bootstrapper; when the config selects a github
|
|
4
|
+
// source, `phoebe boot` runs the engine straight from a git checkout rather than
|
|
5
|
+
// the bundled copy. First boot clones the engine repo into a persistent dir;
|
|
6
|
+
// every boot fetches the configured ref and checks it out, so a branch tracks
|
|
7
|
+
// its tip while a pinned SHA or tag pins an exact commit. The clone lives on a
|
|
8
|
+
// persistent volume (keep `PHOEBE_ENGINE_DIR` on one) so later boots fetch into
|
|
9
|
+
// the existing clone instead of re-cloning.
|
|
10
|
+
//
|
|
11
|
+
// Auth reuses `GH_TOKEN` (the same token every `gh` call uses), embedded as an
|
|
12
|
+
// `x-access-token` HTTPS credential. The token is passed explicitly to each
|
|
13
|
+
// clone/fetch and scrubbed from the persisted `origin` URL, so a rotated token
|
|
14
|
+
// keeps working and no secret is written to the volume's git config.
|
|
15
|
+
//
|
|
16
|
+
// The git mechanics reuse the engine's injectable GitRunner seam
|
|
17
|
+
// (src/git-model.ts) so this materializer is unit-tested without running git.
|
|
18
|
+
//
|
|
19
|
+
// This module also owns the *ref watch* the reconcile loop polls (#42): asking
|
|
20
|
+
// the remote where the configured ref points now, so `phoebe boot` can notice
|
|
21
|
+
// the tracked branch has advanced past the commit the running engine was checked
|
|
22
|
+
// out at. Only a moving branch is watched — a pinned SHA or tag is inert.
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { defaultGit, type GitRunner } from "../src/git-model.ts";
|
|
27
|
+
|
|
28
|
+
/** The resolved github source the materializer acts on (defaults already applied). */
|
|
29
|
+
export type GithubSource = { source: "github"; ref: string; repo: string };
|
|
30
|
+
|
|
31
|
+
/** The persistent clone directory for a given engine repo, keyed by repo slug. */
|
|
32
|
+
export function githubEngineDir(baseDir: string, repo: string): string {
|
|
33
|
+
return join(baseDir, "github", repo.replace(/[^a-zA-Z0-9]+/g, "-"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The HTTPS URL git clones/fetches from. With a token it carries an
|
|
38
|
+
* `x-access-token` credential (GitHub's convention for PAT/app tokens); without
|
|
39
|
+
* one it is the plain URL, so public repos and anonymous local dev still work.
|
|
40
|
+
*/
|
|
41
|
+
export function buildAuthenticatedRepoUrl(repo: string, token: string | undefined): string {
|
|
42
|
+
if (!token) return `https://github.com/${repo}.git`;
|
|
43
|
+
return `https://x-access-token:${token}@github.com/${repo}.git`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Ensure a git checkout of `repo` at `ref` exists under `baseDir` and return the
|
|
48
|
+
* engine-CLI path (`<clone>/src/cli.ts`) `phoebe boot` execs, plus the commit it
|
|
49
|
+
* landed on. Clones only when the clone is absent; otherwise fetches into the
|
|
50
|
+
* existing one. `ref` (branch, 40-char SHA, or tag) is fetched by name and
|
|
51
|
+
* checked out via `FETCH_HEAD`, so a branch lands on its current tip and a
|
|
52
|
+
* SHA/tag on that exact commit.
|
|
53
|
+
*
|
|
54
|
+
* The returned `sha` is what the reconcile ref-watch compares the remote tip
|
|
55
|
+
* against — it is the commit actually running, so a relaunch is triggered by the
|
|
56
|
+
* branch advancing rather than by anything the poll itself remembers.
|
|
57
|
+
*
|
|
58
|
+
* `git`/`exists` are injectable so the command sequence is unit-tested without a
|
|
59
|
+
* real repository or network.
|
|
60
|
+
*/
|
|
61
|
+
export function materializeGithubEngine(
|
|
62
|
+
source: GithubSource,
|
|
63
|
+
deps: {
|
|
64
|
+
baseDir: string;
|
|
65
|
+
token?: string | undefined;
|
|
66
|
+
git?: GitRunner;
|
|
67
|
+
exists?: (path: string) => boolean;
|
|
68
|
+
},
|
|
69
|
+
): { entry: string; sha: string | null } {
|
|
70
|
+
const git = deps.git ?? defaultGit;
|
|
71
|
+
const exists = deps.exists ?? existsSync;
|
|
72
|
+
const dir = githubEngineDir(deps.baseDir, source.repo);
|
|
73
|
+
const authUrl = buildAuthenticatedRepoUrl(source.repo, deps.token);
|
|
74
|
+
const cleanUrl = buildAuthenticatedRepoUrl(source.repo, undefined);
|
|
75
|
+
|
|
76
|
+
let sha: string | null;
|
|
77
|
+
try {
|
|
78
|
+
if (!exists(join(dir, ".git"))) {
|
|
79
|
+
// Clone into a pre-created empty dir (git needs the leading dirs to exist).
|
|
80
|
+
// Inlined rather than reusing git-model's `ensureClone`: this has to inject
|
|
81
|
+
// `exists` (for testing) and interleave the token-scrub below, neither of
|
|
82
|
+
// which that helper exposes.
|
|
83
|
+
mkdirSync(dir, { recursive: true });
|
|
84
|
+
git(["clone", authUrl, dir], { stdio: "inherit" });
|
|
85
|
+
// Drop the token from the persisted `origin` URL — the fetch below
|
|
86
|
+
// re-supplies it explicitly, so a rotated token keeps working and the
|
|
87
|
+
// volume's git config never holds the secret. The token is still passed on
|
|
88
|
+
// git's argv (clone/fetch); that is acceptable here because `GH_TOKEN`
|
|
89
|
+
// already lives in the container env and boot runs before any agent child.
|
|
90
|
+
git(["-C", dir, "remote", "set-url", "origin", cleanUrl]);
|
|
91
|
+
}
|
|
92
|
+
// Fetch the ref by name (works for branch/tag/reachable-SHA on GitHub) and
|
|
93
|
+
// detach onto exactly what was fetched — latest for a branch, pinned for a
|
|
94
|
+
// SHA/tag. `--force` lets a moved tag/branch and a dirty tree update cleanly.
|
|
95
|
+
git(["-C", dir, "fetch", "--force", "--tags", authUrl, source.ref], { stdio: "inherit" });
|
|
96
|
+
git(["-C", dir, "checkout", "--force", "--detach", "FETCH_HEAD"], { stdio: "inherit" });
|
|
97
|
+
sha = git(["-C", dir, "rev-parse", "HEAD"]).trim() || null;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Failed to materialize the engine from ${source.repo}@${source.ref}: ` +
|
|
101
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { entry: join(dir, "src", "cli.ts"), sha };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A 40-char hex ref is an exact commit — nothing for the ref-watch to track. */
|
|
109
|
+
export function isPinnedSha(ref: string): boolean {
|
|
110
|
+
return /^[0-9a-f]{40}$/i.test(ref);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Bound on the reconcile poll's `ls-remote`. It runs on the bootstrapper's event
|
|
115
|
+
* loop (`execFileSync`), so a hung remote must not stall boot's SIGTERM latch or
|
|
116
|
+
* its child-exit handling — a timed-out poll surfaces as a sample error and is
|
|
117
|
+
* treated as "no change", not a crash.
|
|
118
|
+
*/
|
|
119
|
+
export const LS_REMOTE_TIMEOUT_MS = 30_000;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse `git ls-remote` output into `{ sha, refName }` rows. Each line is
|
|
123
|
+
* `<sha>\t<refname>`; peeled tag lines (`refs/tags/v1^{}`) come through as
|
|
124
|
+
* ordinary rows and are simply never matched as branches.
|
|
125
|
+
*/
|
|
126
|
+
export function parseLsRemote(output: string): Array<{ sha: string; refName: string }> {
|
|
127
|
+
const rows: Array<{ sha: string; refName: string }> = [];
|
|
128
|
+
for (const line of output.split("\n")) {
|
|
129
|
+
const [sha, refName] = line.trim().split(/\s+/);
|
|
130
|
+
if (sha && refName) rows.push({ sha, refName });
|
|
131
|
+
}
|
|
132
|
+
return rows;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The tip `ref` currently points at, but only when `ref` names a **branch** —
|
|
137
|
+
* the one kind of ref that moves under a running engine. A tag (or a ref the
|
|
138
|
+
* remote does not have) yields null, which the reconcile loop reads as "nothing
|
|
139
|
+
* to watch": per #42 a pinned SHA/tag never triggers a relaunch, even if someone
|
|
140
|
+
* force-moves the tag.
|
|
141
|
+
*
|
|
142
|
+
* Accepts both the short form (`main`) and a fully-qualified `refs/heads/main`.
|
|
143
|
+
*/
|
|
144
|
+
export function branchShaFromLsRemote(output: string, ref: string): string | null {
|
|
145
|
+
const wanted = ref.startsWith("refs/") ? ref : `refs/heads/${ref}`;
|
|
146
|
+
if (!wanted.startsWith("refs/heads/")) return null;
|
|
147
|
+
return parseLsRemote(output).find((row) => row.refName === wanted)?.sha ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Ask the remote where the configured branch points now — the ref half of a
|
|
152
|
+
* reconcile poll, and deliberately the *only* network call it makes: `ls-remote`
|
|
153
|
+
* reads a ref without fetching any objects. Returns null when there is nothing
|
|
154
|
+
* to watch (pinned SHA — not even asked; tag; unknown ref).
|
|
155
|
+
*
|
|
156
|
+
* The token rides on the URL exactly as it does for clone/fetch (same tradeoff,
|
|
157
|
+
* documented above): `GH_TOKEN` is already in the container env and boot runs
|
|
158
|
+
* before any agent child.
|
|
159
|
+
*/
|
|
160
|
+
export function lsRemoteBranchSha(
|
|
161
|
+
source: GithubSource,
|
|
162
|
+
deps: { token?: string | undefined; git?: GitRunner } = {},
|
|
163
|
+
): string | null {
|
|
164
|
+
if (isPinnedSha(source.ref)) return null;
|
|
165
|
+
const git = deps.git ?? defaultGit;
|
|
166
|
+
const url = buildAuthenticatedRepoUrl(source.repo, deps.token);
|
|
167
|
+
const output = git(["ls-remote", url, source.ref], { timeout: LS_REMOTE_TIMEOUT_MS });
|
|
168
|
+
return branchShaFromLsRemote(output, source.ref);
|
|
169
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Runtime surface of the `phoebe-agent` package — what a consumer's
|
|
2
|
+
// `phoebe.config.ts` resolves when it imports from `phoebe-agent`. Node resolves
|
|
3
|
+
// a bare package specifier into `node_modules`, where Node 24 won't type-strip
|
|
4
|
+
// `.ts`, so this entry must be JS. It is trivially small: the only thing a
|
|
5
|
+
// consumer calls at runtime is `defineConfig`, an identity helper (its typed
|
|
6
|
+
// signature lives in index.ts, the package `types` entry). Everything else the
|
|
7
|
+
// package exposes is types-only. The bootstrapper's real logic is TypeScript —
|
|
8
|
+
// see bootstrap/cli.ts, reached via the bin launcher (bootstrap/bin.mjs).
|
|
9
|
+
export const defineConfig = (config) => config;
|