phoebe-agent 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +77 -8
  2. package/bootstrap/bin.mjs +42 -0
  3. package/bootstrap/boot.ts +431 -0
  4. package/bootstrap/cli.ts +29 -0
  5. package/bootstrap/crash-loop.ts +391 -0
  6. package/bootstrap/define-config.ts +20 -0
  7. package/bootstrap/engine-source.ts +83 -0
  8. package/bootstrap/github-engine.ts +169 -0
  9. package/bootstrap/index.mjs +9 -0
  10. package/bootstrap/index.ts +24 -0
  11. package/bootstrap/materialize.mjs +53 -0
  12. package/bootstrap/reconcile.ts +314 -0
  13. package/bootstrap/spawn-engine.mjs +78 -0
  14. package/package.json +26 -24
  15. package/prompts/checks-prompt.md +21 -6
  16. package/prompts/conflict-prompt.md +12 -1
  17. package/prompts/research-prompt.md +61 -0
  18. package/src/agent-env.ts +32 -0
  19. package/src/branded.ts +19 -0
  20. package/src/cli.ts +187 -0
  21. package/src/config-schema.ts +278 -0
  22. package/src/drain.ts +74 -0
  23. package/src/execution-gate.ts +27 -0
  24. package/src/git-model.ts +145 -0
  25. package/src/init.ts +277 -0
  26. package/src/load-config.ts +168 -0
  27. package/src/main.ts +1636 -0
  28. package/src/orchestrator.ts +953 -0
  29. package/src/prompt.ts +98 -0
  30. package/src/providers/providers.ts +222 -0
  31. package/src/providers/run-agent.ts +90 -0
  32. package/src/providers/types.ts +26 -0
  33. package/src/resolved-config.ts +55 -0
  34. package/templates/.env.example +17 -5
  35. package/templates/container/Dockerfile +128 -19
  36. package/templates/container/compose.local.yml +37 -0
  37. package/templates/container/compose.yml +43 -13
  38. package/templates/phoebe.config.ts +30 -6
  39. package/dist/phoebe.config.d.ts +0 -2
  40. package/dist/phoebe.config.js +0 -43
  41. package/dist/src/agent-env.d.ts +0 -6
  42. package/dist/src/agent-env.js +0 -24
  43. package/dist/src/cli.d.ts +0 -25
  44. package/dist/src/cli.js +0 -161
  45. package/dist/src/config-schema.d.ts +0 -163
  46. package/dist/src/config-schema.js +0 -140
  47. package/dist/src/execution-gate.d.ts +0 -9
  48. package/dist/src/execution-gate.js +0 -17
  49. package/dist/src/git-model.d.ts +0 -30
  50. package/dist/src/git-model.js +0 -71
  51. package/dist/src/index.d.ts +0 -2
  52. package/dist/src/index.js +0 -12
  53. package/dist/src/init.d.ts +0 -82
  54. package/dist/src/init.js +0 -207
  55. package/dist/src/load-config.d.ts +0 -88
  56. package/dist/src/load-config.js +0 -153
  57. package/dist/src/main.d.ts +0 -7
  58. package/dist/src/main.js +0 -1180
  59. package/dist/src/orchestrator.d.ts +0 -275
  60. package/dist/src/orchestrator.js +0 -579
  61. package/dist/src/prompt.d.ts +0 -13
  62. package/dist/src/prompt.js +0 -55
  63. package/dist/src/providers/providers.d.ts +0 -3
  64. package/dist/src/providers/providers.js +0 -210
  65. package/dist/src/providers/run-agent.d.ts +0 -34
  66. package/dist/src/providers/run-agent.js +0 -57
  67. package/dist/src/providers/types.d.ts +0 -27
  68. package/dist/src/providers/types.js +0 -6
  69. package/dist/src/resolved-config.d.ts +0 -8
  70. package/dist/src/resolved-config.js +0 -49
  71. package/dist/src/supervisor-decision.d.ts +0 -70
  72. package/dist/src/supervisor-decision.js +0 -94
  73. package/templates/container/compose.daemon.yml +0 -16
  74. package/templates/container/supervisor.sh +0 -50
  75. /package/prompts/{prompt.md → issues-prompt.md} +0 -0
package/src/main.ts ADDED
@@ -0,0 +1,1636 @@
1
+ // Phoebe orchestration engine — an away-from-keyboard (AFK) worker loop.
2
+ //
3
+ // Picks ready-labelled issues off the configured repo one at a time and
4
+ // works each in a git worktree off the container's private clone, on its own
5
+ // branch, opening a PR to the default branch. The container is both
6
+ // orchestrator and execution environment; agent CLIs run as direct children
7
+ // with an allowlisted env. See docs/architecture.md for the full design.
8
+ //
9
+ // The `runEngine(argv)` export is the loop entry point invoked by src/cli.ts
10
+ // after it loads the consumer's phoebe.config.ts and installs the resolved
11
+ // config into src/resolved-config.ts. Recognised argv flags:
12
+ //
13
+ // (no flags) # persistent poll loop
14
+ // --run-once # one unit of the first one-shot-eligible kind
15
+ // --dry-run --run-once # host-side selection preview
16
+ //
17
+ // Work-unit execution is refused outside the container marker
18
+ // (src/execution-gate.ts).
19
+
20
+ import { execFileSync, execSync } from "node:child_process";
21
+ import { config } from "./resolved-config.ts";
22
+ import { PROVIDER_NAMES, type ProviderName } from "./config-schema.ts";
23
+ import {
24
+ asBranchRef,
25
+ asPrNumber,
26
+ asSha,
27
+ type BranchRef,
28
+ type PrNumber,
29
+ type Sha,
30
+ } from "./branded.ts";
31
+ import { buildAgentEnv } from "./agent-env.ts";
32
+ import { installDrainSignal, type DrainSignal } from "./drain.ts";
33
+ import {
34
+ EXECUTION_REFUSED_MESSAGE,
35
+ executionDecision,
36
+ isInsideContainer,
37
+ } from "./execution-gate.ts";
38
+ import {
39
+ addWorktreeForExistingBranch,
40
+ addWorktreeForNewBranch,
41
+ commitCount,
42
+ ensureClone,
43
+ fetchOrigin as gitFetchOrigin,
44
+ originBranchSha as gitOriginBranchSha,
45
+ pushBranch,
46
+ removeWorktree,
47
+ worktreeDirForBranch,
48
+ } from "./git-model.ts";
49
+ import { PROVIDERS } from "./providers/providers.ts";
50
+ import { runAgent } from "./providers/run-agent.ts";
51
+ import type { Provider } from "./providers/types.ts";
52
+ import {
53
+ buildDefaultPromptArgs,
54
+ loadPromptTemplate as loadPromptTemplateFromRoot,
55
+ renderPrompt,
56
+ } from "./prompt.ts";
57
+ import {
58
+ buildInitialPrBody,
59
+ buildReviewsHandledComment,
60
+ checksFixFailureComment,
61
+ conflictFixFailureComment,
62
+ followUpPrComment,
63
+ formatFailingChecksForPrompt,
64
+ isReviewSummaryComment,
65
+ issueBranch,
66
+ isPrInScope,
67
+ isPrMergeConflicting,
68
+ listFailingChecks,
69
+ newestReviewThreadCommentCreatedAt,
70
+ parseBlockedBy,
71
+ parseLatestMarker,
72
+ parseChecksFailWatermark,
73
+ parseConflictFailWatermark,
74
+ parseReviewsHandledWatermark,
75
+ parseIssueNumberFromBranch,
76
+ getMergedBlockerPrNumbers,
77
+ oneShotWorkKinds,
78
+ stackedCatchUpRetractionComment,
79
+ RUN_ONCE_NOTHING_MESSAGE,
80
+ selectFirstWorkUnit,
81
+ selectIssue,
82
+ summarizeChecksSelection,
83
+ summarizeConflictSelection,
84
+ summarizeReviewsSelection,
85
+ shouldPostChecksFixFailure,
86
+ shouldPostConflictFixFailure,
87
+ statusCheckRollupState,
88
+ validateWorkOrder,
89
+ workflowRunsToCheckItems,
90
+ type BlockerPrState,
91
+ type ChecksCandidate,
92
+ type ChecksFailWatermark,
93
+ type ConflictingPrCandidate,
94
+ type ConflictFailWatermark,
95
+ type Issue,
96
+ type IssueWorkUnit,
97
+ type ReviewThread,
98
+ type ReviewsCandidate,
99
+ type StackContext,
100
+ type StatusCheckItem,
101
+ type WorkflowRunItem,
102
+ type WorkKindName,
103
+ type WorkUnit,
104
+ } from "./orchestrator.ts";
105
+
106
+ const DEFAULT_POLL_INTERVAL_MS = 300_000;
107
+ // Never let a gh/git child process block the persistent loop forever (rate-limit
108
+ // backoff, credential prompt, network partition). Configured toolchain commands
109
+ // (install/test) get a longer leash.
110
+ const CHILD_PROCESS_TIMEOUT_MS = 120_000;
111
+ const SHELL_COMMAND_TIMEOUT_MS = 600_000;
112
+ const MERGEABLE_RETRY_MS = 5_000;
113
+ const MERGEABLE_RETRY_COUNT = 3;
114
+
115
+ const PR_BASE = config.defaultBranch;
116
+ const defaultBranchRef = asBranchRef(config.defaultBranch);
117
+
118
+ const inContainer = isInsideContainer();
119
+ // On the host only selection/--dry-run runs, against the local checkout; in
120
+ // the container all git state lives in the private clone on the named volume.
121
+ const repoDir = inContainer ? config.paths.repoDir : process.cwd();
122
+ const worktreesDir = config.paths.worktreesDir;
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Provider selection (multi-provider ready)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function selectProvider(): { provider: Provider; model: string } {
129
+ const name = process.env["PHOEBE_AGENT"] ?? config.defaultProvider;
130
+ if (!(PROVIDER_NAMES as readonly string[]).includes(name)) {
131
+ throw new Error(`Unknown PHOEBE_AGENT "${name}". Use one of: ${PROVIDER_NAMES.join(", ")}.`);
132
+ }
133
+ const provider = PROVIDERS[name as ProviderName];
134
+ const model = process.env["PHOEBE_MODEL"] ?? config.defaultModels[name as ProviderName];
135
+ return { provider, model };
136
+ }
137
+
138
+ const workOrder = validateWorkOrder(config.workOrder);
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // gh helpers — always pinned to the configured repo
142
+ // ---------------------------------------------------------------------------
143
+
144
+ function ghJson<T>(args: string[]): T {
145
+ return JSON.parse(
146
+ execFileSync("gh", [...args, "-R", config.repoSlug], {
147
+ encoding: "utf8",
148
+ timeout: CHILD_PROCESS_TIMEOUT_MS,
149
+ }),
150
+ ) as T;
151
+ }
152
+
153
+ function ghApiJson<T>(endpoint: string): T {
154
+ return JSON.parse(
155
+ execFileSync("gh", ["api", endpoint], {
156
+ encoding: "utf8",
157
+ timeout: CHILD_PROCESS_TIMEOUT_MS,
158
+ }),
159
+ ) as T;
160
+ }
161
+
162
+ function gh(args: string[], opts?: { input?: string }): void {
163
+ execFileSync("gh", [...args, "-R", config.repoSlug], {
164
+ stdio: opts?.input !== undefined ? ["pipe", "inherit", "inherit"] : "inherit",
165
+ timeout: CHILD_PROCESS_TIMEOUT_MS,
166
+ ...(opts?.input !== undefined ? { input: opts.input } : {}),
167
+ });
168
+ }
169
+
170
+ /** Open issues carrying `label`, oldest-created first. Shared by `issues` and `research`. */
171
+ function listIssuesWithLabel(label: string): Issue[] {
172
+ type GhIssue = Omit<Issue, "labels"> & { labels: Array<{ name: string }> };
173
+ return ghJson<GhIssue[]>([
174
+ "issue",
175
+ "list",
176
+ "--state",
177
+ "open",
178
+ "--label",
179
+ label,
180
+ "--limit",
181
+ "100",
182
+ "--search",
183
+ "sort:created-asc",
184
+ "--json",
185
+ "number,title,body,labels,createdAt",
186
+ ]).map((row) => ({
187
+ number: row.number,
188
+ title: row.title,
189
+ body: row.body,
190
+ createdAt: row.createdAt,
191
+ labels: row.labels.map((l) => l.name),
192
+ }));
193
+ }
194
+
195
+ function listReadyIssues(): Issue[] {
196
+ return listIssuesWithLabel(config.readyLabel);
197
+ }
198
+
199
+ function listResearchIssues(): Issue[] {
200
+ return listIssuesWithLabel(config.researchLabel);
201
+ }
202
+
203
+ function blockerPrState(blockerIssueNumber: number): BlockerPrState {
204
+ const branch: BranchRef = issueBranch(blockerIssueNumber);
205
+ const open = ghJson<Array<{ number: number }>>([
206
+ "pr",
207
+ "list",
208
+ "--head",
209
+ branch,
210
+ "--state",
211
+ "open",
212
+ "--json",
213
+ "number",
214
+ "--limit",
215
+ "1",
216
+ ]);
217
+ const merged = ghJson<Array<{ number: number }>>([
218
+ "pr",
219
+ "list",
220
+ "--head",
221
+ branch,
222
+ "--state",
223
+ "merged",
224
+ "--json",
225
+ "number",
226
+ "--limit",
227
+ "1",
228
+ ]);
229
+ return {
230
+ hasOpenPr: open.length > 0,
231
+ openPrNumber: open[0] ? asPrNumber(open[0].number) : undefined,
232
+ hasMergedPr: merged.length > 0,
233
+ mergedPrNumber: merged[0] ? asPrNumber(merged[0].number) : undefined,
234
+ };
235
+ }
236
+
237
+ function buildBlockerStates(issues: readonly Issue[]): Map<number, BlockerPrState> {
238
+ const blockerNumbers = new Set<number>();
239
+ for (const issue of issues) {
240
+ for (const n of parseBlockedBy(issue.body)) {
241
+ blockerNumbers.add(n);
242
+ }
243
+ }
244
+ const states = new Map<number, BlockerPrState>();
245
+ for (const n of blockerNumbers) {
246
+ try {
247
+ states.set(n, blockerPrState(n));
248
+ } catch (error) {
249
+ // Absent entries are treated as unmerged blockers — safe to retry next cycle.
250
+ console.warn(
251
+ `[phoebe] Skipping blocker state for #${n} this cycle — ${error instanceof Error ? error.message : String(error)}`,
252
+ );
253
+ }
254
+ }
255
+ return states;
256
+ }
257
+
258
+ function buildBlockerStatesFromBodies(
259
+ bodies: ReadonlyArray<{ number: number; body: string }>,
260
+ ): Map<number, BlockerPrState> {
261
+ return buildBlockerStates(
262
+ bodies.map(({ number, body }) => ({
263
+ number,
264
+ title: "",
265
+ body,
266
+ labels: [],
267
+ createdAt: "",
268
+ })),
269
+ );
270
+ }
271
+
272
+ function postPrComment(prNumber: PrNumber, body: string): void {
273
+ gh(["pr", "comment", String(prNumber), "--body", body]);
274
+ }
275
+
276
+ type OpenPhoebePr = { number: PrNumber; headRefName: BranchRef; authorLogin: string };
277
+
278
+ function listOpenPhoebePrs(): OpenPhoebePr[] {
279
+ type GhOpenPr = {
280
+ number: number;
281
+ headRefName: string;
282
+ isDraft: boolean;
283
+ isCrossRepository: boolean;
284
+ labels: Array<{ name: string }>;
285
+ author: { login: string };
286
+ };
287
+ return ghJson<GhOpenPr[]>([
288
+ "pr",
289
+ "list",
290
+ "--base",
291
+ PR_BASE,
292
+ "--state",
293
+ "open",
294
+ "--json",
295
+ "number,headRefName,isDraft,isCrossRepository,labels,author",
296
+ "--limit",
297
+ "100",
298
+ ])
299
+ .filter((pr) =>
300
+ isPrInScope({
301
+ headRefName: asBranchRef(pr.headRefName),
302
+ isDraft: pr.isDraft,
303
+ isCrossRepository: pr.isCrossRepository,
304
+ labels: pr.labels.map((label) => label.name),
305
+ }),
306
+ )
307
+ .map((pr) => ({
308
+ number: asPrNumber(pr.number),
309
+ headRefName: asBranchRef(pr.headRefName),
310
+ authorLogin: pr.author.login,
311
+ }));
312
+ }
313
+
314
+ type PrMergeInfo = {
315
+ number: PrNumber;
316
+ headRefName: BranchRef;
317
+ headRefOid: Sha;
318
+ mergeable: string;
319
+ mergeStateStatus: string;
320
+ };
321
+
322
+ function viewPrMergeInfo(prNumber: PrNumber): PrMergeInfo {
323
+ const raw = ghJson<{
324
+ number: number;
325
+ headRefName: string;
326
+ headRefOid: string;
327
+ mergeable: string;
328
+ mergeStateStatus: string;
329
+ }>([
330
+ "pr",
331
+ "view",
332
+ String(prNumber),
333
+ "--json",
334
+ "number,headRefName,headRefOid,mergeable,mergeStateStatus",
335
+ ]);
336
+ return {
337
+ number: asPrNumber(raw.number),
338
+ headRefName: asBranchRef(raw.headRefName),
339
+ headRefOid: asSha(raw.headRefOid),
340
+ mergeable: raw.mergeable,
341
+ mergeStateStatus: raw.mergeStateStatus,
342
+ };
343
+ }
344
+
345
+ /** All comment bodies on a PR, oldest first — the raw input to every watermark parse. */
346
+ function fetchPrCommentBodies(prNumber: PrNumber): string[] {
347
+ const { comments } = ghJson<{ comments: Array<{ body: string }> }>([
348
+ "pr",
349
+ "view",
350
+ String(prNumber),
351
+ "--json",
352
+ "comments",
353
+ ]);
354
+ return comments.map((comment) => comment.body);
355
+ }
356
+
357
+ function phoebeGhLogin(): string {
358
+ return ghApiJson<{ login: string }>("user").login;
359
+ }
360
+
361
+ function issueBody(issueNumber: number): string {
362
+ return ghJson<{ body: string }>(["issue", "view", String(issueNumber), "--json", "body"]).body;
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // git helpers bound to the clone
367
+ // ---------------------------------------------------------------------------
368
+
369
+ function fetchOrigin(): void {
370
+ gitFetchOrigin(repoDir);
371
+ }
372
+
373
+ function originBranchSha(branch: BranchRef): Sha {
374
+ return gitOriginBranchSha(repoDir, branch);
375
+ }
376
+
377
+ function currentConflictFailureWatermark(branch: BranchRef): ConflictFailWatermark {
378
+ fetchOrigin();
379
+ return {
380
+ prHead: originBranchSha(branch),
381
+ mainHead: originBranchSha(defaultBranchRef),
382
+ };
383
+ }
384
+
385
+ function currentChecksFailureWatermark(branch: BranchRef): ChecksFailWatermark {
386
+ fetchOrigin();
387
+ return { prHead: originBranchSha(branch) };
388
+ }
389
+
390
+ function gitInWorktree(
391
+ worktreeDir: string,
392
+ args: string[],
393
+ opts?: { stdio?: "inherit" | "ignore" | "pipe" },
394
+ ): string {
395
+ return execFileSync("git", ["-C", worktreeDir, ...args], {
396
+ encoding: "utf8",
397
+ timeout: CHILD_PROCESS_TIMEOUT_MS,
398
+ ...(opts?.stdio ? { stdio: opts.stdio } : {}),
399
+ }) as unknown as string;
400
+ }
401
+
402
+ /** Run a configured toolchain command (a shell string) inside a worktree. */
403
+ function runShellCommand(command: string, cwd: string): void {
404
+ execSync(command, { cwd, stdio: "inherit", timeout: SHELL_COMMAND_TIMEOUT_MS });
405
+ }
406
+
407
+ /** Shell executor for prompt !`...` expansion — captures stdout. */
408
+ function promptShell(cwd: string): (command: string) => string {
409
+ return (command) =>
410
+ execSync(command, { cwd, encoding: "utf8", timeout: SHELL_COMMAND_TIMEOUT_MS });
411
+ }
412
+
413
+ /** Load a `promptFiles.*` template from the runtime root (process cwd). */
414
+ function loadPromptTemplate(relativePath: string): string {
415
+ return loadPromptTemplateFromRoot(relativePath, process.cwd());
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Work-unit execution
420
+ // ---------------------------------------------------------------------------
421
+
422
+ function prepareWorktree(opts: { branch: BranchRef; baseRef?: string }): string {
423
+ const worktreeDir = worktreeDirForBranch(worktreesDir, opts.branch);
424
+ removeWorktree(repoDir, worktreeDir);
425
+ if (opts.baseRef) {
426
+ addWorktreeForNewBranch({
427
+ repoDir,
428
+ worktreeDir,
429
+ branch: opts.branch,
430
+ baseRef: opts.baseRef,
431
+ });
432
+ } else {
433
+ addWorktreeForExistingBranch({ repoDir, worktreeDir, branch: opts.branch });
434
+ }
435
+ return worktreeDir;
436
+ }
437
+
438
+ async function runAgentInWorktree(opts: {
439
+ worktreeDir: string;
440
+ promptFile: string;
441
+ promptArgs: Record<string, string>;
442
+ }): Promise<void> {
443
+ const { provider, model } = selectProvider();
444
+ // Caller-supplied per-callsite args (ISSUE_NUMBER, PR_NUMBER, …) override
445
+ // the standard config-derived set by key.
446
+ const prompt = renderPrompt(
447
+ loadPromptTemplate(opts.promptFile),
448
+ { ...buildDefaultPromptArgs(config), ...opts.promptArgs },
449
+ promptShell(opts.worktreeDir),
450
+ );
451
+ const env = buildAgentEnv({
452
+ parentEnv: process.env,
453
+ provider: provider.name,
454
+ providerEnv: config.providerEnv,
455
+ });
456
+ const { exitCode } = await runAgent({
457
+ provider,
458
+ model,
459
+ prompt,
460
+ cwd: opts.worktreeDir,
461
+ env,
462
+ });
463
+ if (exitCode !== 0) {
464
+ console.log(`[phoebe] Agent exited with code ${exitCode}.`);
465
+ }
466
+ }
467
+
468
+ // The observed outcome of an automatic (no-agent) merge attempt:
469
+ // "pushed" — merged cleanly and pushed; the PR is caught up.
470
+ // "conflicted" — real merge conflicts in the tree; an agent must resolve them.
471
+ // "failed" — could not even start/finish the merge (e.g. worktree setup);
472
+ // no conflicts were observed.
473
+ type CleanMergeOutcome = "pushed" | "conflicted" | "failed";
474
+
475
+ function tryCleanMerge(
476
+ branch: BranchRef,
477
+ mergedBlockerPrNumbers: readonly PrNumber[] = [],
478
+ ): CleanMergeOutcome {
479
+ let worktreeDir: string;
480
+ try {
481
+ worktreeDir = prepareWorktree({ branch });
482
+ } catch {
483
+ return "failed";
484
+ }
485
+
486
+ try {
487
+ for (const blockerPrNumber of mergedBlockerPrNumbers) {
488
+ gitInWorktree(worktreeDir, ["fetch", "origin", `pull/${blockerPrNumber}/head`], {
489
+ stdio: "inherit",
490
+ });
491
+ gitInWorktree(worktreeDir, ["merge", "FETCH_HEAD"], { stdio: "pipe" });
492
+ }
493
+ gitInWorktree(worktreeDir, ["fetch", "origin", config.defaultBranch], { stdio: "inherit" });
494
+ gitInWorktree(worktreeDir, ["merge", `origin/${config.defaultBranch}`], { stdio: "pipe" });
495
+ pushBranch(worktreeDir, branch);
496
+ removeWorktree(repoDir, worktreeDir);
497
+ return "pushed";
498
+ } catch {
499
+ try {
500
+ const unmerged = gitInWorktree(worktreeDir, ["diff", "--name-only", "--diff-filter=U"]);
501
+ if (unmerged.trim()) {
502
+ gitInWorktree(worktreeDir, ["merge", "--abort"], { stdio: "ignore" });
503
+ removeWorktree(repoDir, worktreeDir);
504
+ return "conflicted";
505
+ }
506
+ } catch {
507
+ // Fall through to failed.
508
+ }
509
+ try {
510
+ gitInWorktree(worktreeDir, ["merge", "--abort"], { stdio: "ignore" });
511
+ } catch {
512
+ // Best-effort.
513
+ }
514
+ removeWorktree(repoDir, worktreeDir);
515
+ return "failed";
516
+ }
517
+ }
518
+
519
+ /** Blocker-first merge attempt, mirroring `cmd && … || true` hook semantics. */
520
+ function attemptBlockerFirstMerges(
521
+ worktreeDir: string,
522
+ mergedBlockerPrNumbers: readonly PrNumber[],
523
+ ): void {
524
+ try {
525
+ for (const n of mergedBlockerPrNumbers) {
526
+ gitInWorktree(worktreeDir, ["fetch", "origin", `pull/${n}/head`], { stdio: "inherit" });
527
+ gitInWorktree(worktreeDir, ["merge", "FETCH_HEAD"], { stdio: "pipe" });
528
+ }
529
+ gitInWorktree(worktreeDir, ["fetch", "origin", config.defaultBranch], { stdio: "inherit" });
530
+ gitInWorktree(worktreeDir, ["merge", `origin/${config.defaultBranch}`], { stdio: "pipe" });
531
+ } catch {
532
+ // Conflicts stay in the tree for the agent to resolve.
533
+ }
534
+ }
535
+
536
+ type AgentWorkflowResult = {
537
+ worktreeDir: string;
538
+ branch: BranchRef;
539
+ originShaBefore: Sha;
540
+ originShaAfter: Sha;
541
+ localCommitCount: number;
542
+ };
543
+
544
+ /**
545
+ * The shared skeleton behind every PR-fix agent: snapshot origin, prepare a
546
+ * worktree, install, optionally prime the tree, run the agent, then re-snapshot
547
+ * origin and count the host-side commits. Only `onResult` differs per work kind
548
+ * (push vs. failure comment vs. watermark); the worktree is always removed.
549
+ */
550
+ async function runAgentWorkflow(opts: {
551
+ pr: { prNumber: PrNumber; headRefName: BranchRef };
552
+ promptFile: string;
553
+ promptArgs: Record<string, string>;
554
+ beforeAgent?: (worktreeDir: string) => void;
555
+ onResult: (result: AgentWorkflowResult) => void | Promise<void>;
556
+ }): Promise<void> {
557
+ const branch = opts.pr.headRefName;
558
+
559
+ fetchOrigin();
560
+ const originShaBefore = originBranchSha(branch);
561
+
562
+ const worktreeDir = prepareWorktree({ branch });
563
+ try {
564
+ runShellCommand(config.installCommand, worktreeDir);
565
+ opts.beforeAgent?.(worktreeDir);
566
+
567
+ await runAgentInWorktree({
568
+ worktreeDir,
569
+ promptFile: opts.promptFile,
570
+ promptArgs: opts.promptArgs,
571
+ });
572
+
573
+ fetchOrigin();
574
+ const originShaAfter = originBranchSha(branch);
575
+ const localCommitCount = commitCount(worktreeDir, `origin/${branch}..HEAD`);
576
+
577
+ await opts.onResult({ worktreeDir, branch, originShaBefore, originShaAfter, localCommitCount });
578
+ } finally {
579
+ removeWorktree(repoDir, worktreeDir);
580
+ }
581
+ }
582
+
583
+ async function runConflictResolutionAgent(
584
+ pr: ConflictingPrCandidate,
585
+ mergedBlockerPrNumbers: readonly PrNumber[],
586
+ ): Promise<void> {
587
+ await runAgentWorkflow({
588
+ pr,
589
+ promptFile: config.promptFiles.conflict,
590
+ promptArgs: {
591
+ PR_NUMBER: String(pr.prNumber),
592
+ PR_BRANCH: pr.headRefName,
593
+ BLOCKER_PR_NUMBERS: mergedBlockerPrNumbers.join(","),
594
+ },
595
+ beforeAgent: (worktreeDir) => attemptBlockerFirstMerges(worktreeDir, mergedBlockerPrNumbers),
596
+ onResult: ({ worktreeDir, branch, originShaBefore, originShaAfter, localCommitCount }) => {
597
+ const prInfo = viewPrMergeInfo(pr.prNumber);
598
+ if (
599
+ shouldPostConflictFixFailure({
600
+ hostCommitCount: localCommitCount,
601
+ originShaBefore,
602
+ originShaAfter,
603
+ mergeable: prInfo.mergeable,
604
+ mergeStateStatus: prInfo.mergeStateStatus,
605
+ })
606
+ ) {
607
+ console.log(
608
+ `[phoebe] Conflict fix for PR #${pr.prNumber} produced no commits — leaving PR unchanged.`,
609
+ );
610
+ postPrComment(
611
+ pr.prNumber,
612
+ conflictFixFailureComment(pr.prNumber, currentConflictFailureWatermark(pr.headRefName)),
613
+ );
614
+ } else if (localCommitCount > 0) {
615
+ pushBranch(worktreeDir, branch);
616
+ console.log(`[phoebe] Conflict resolved for PR #${pr.prNumber} — pushed.`);
617
+ } else {
618
+ console.log(`[phoebe] Conflict resolved for PR #${pr.prNumber} — already pushed by agent.`);
619
+ }
620
+ },
621
+ });
622
+ }
623
+
624
+ async function fixOnePrConflict(pr: ConflictingPrCandidate, ctx: StackContext): Promise<void> {
625
+ console.log(`[phoebe] Conflict fix: PR #${pr.prNumber} (${pr.headRefName}).`);
626
+ fetchOrigin();
627
+
628
+ const issueNumber = pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName);
629
+ const body = issueNumber !== null ? (ctx.issueBodies.get(issueNumber) ?? "") : "";
630
+ const mergedBlockerPrNumbers = getMergedBlockerPrNumbers(body, ctx.blockerStates);
631
+ if (mergedBlockerPrNumbers.length > 0) {
632
+ console.log(
633
+ `[phoebe] Stacked catch-up: merging blocker PR(s) ${mergedBlockerPrNumbers.map((n) => `#${n}`).join(", ")} before ${config.defaultBranch}.`,
634
+ );
635
+ }
636
+
637
+ const cleanResult = tryCleanMerge(pr.headRefName, mergedBlockerPrNumbers);
638
+ if (cleanResult === "pushed") {
639
+ console.log(`[phoebe] Clean merge for PR #${pr.prNumber} — pushed.`);
640
+ if (mergedBlockerPrNumbers.length > 0) {
641
+ postPrComment(pr.prNumber, stackedCatchUpRetractionComment(mergedBlockerPrNumbers));
642
+ }
643
+ return;
644
+ }
645
+ if (cleanResult === "failed") {
646
+ console.log(`[phoebe] Could not start merge for PR #${pr.prNumber} — skipping.`);
647
+ postPrComment(
648
+ pr.prNumber,
649
+ conflictFixFailureComment(pr.prNumber, currentConflictFailureWatermark(pr.headRefName)),
650
+ );
651
+ return;
652
+ }
653
+
654
+ await runConflictResolutionAgent(pr, mergedBlockerPrNumbers);
655
+ }
656
+
657
+ async function runChecksResolutionAgent(pr: ChecksCandidate): Promise<void> {
658
+ await runAgentWorkflow({
659
+ pr,
660
+ promptFile: config.promptFiles.checks,
661
+ promptArgs: {
662
+ PR_NUMBER: String(pr.prNumber),
663
+ PR_BRANCH: pr.headRefName,
664
+ FAILING_CHECKS: formatFailingChecksForPrompt(pr.failingChecks),
665
+ },
666
+ onResult: ({ worktreeDir, branch, originShaBefore, originShaAfter, localCommitCount }) => {
667
+ if (
668
+ shouldPostChecksFixFailure({
669
+ hostCommitCount: localCommitCount,
670
+ originShaBefore,
671
+ originShaAfter,
672
+ })
673
+ ) {
674
+ console.log(
675
+ `[phoebe] Checks fix for PR #${pr.prNumber} produced no commits — leaving PR unchanged.`,
676
+ );
677
+ postPrComment(
678
+ pr.prNumber,
679
+ checksFixFailureComment(pr.prNumber, currentChecksFailureWatermark(pr.headRefName)),
680
+ );
681
+ } else if (localCommitCount > 0) {
682
+ pushBranch(worktreeDir, branch);
683
+ console.log(`[phoebe] Checks fixed for PR #${pr.prNumber} — pushed.`);
684
+ } else {
685
+ console.log(`[phoebe] Checks fixed for PR #${pr.prNumber} — already pushed by agent.`);
686
+ }
687
+ },
688
+ });
689
+ }
690
+
691
+ async function fixOnePrChecks(pr: ChecksCandidate, ctx: StackContext): Promise<void> {
692
+ console.log(
693
+ `[phoebe] Checks fix: PR #${pr.prNumber} (${pr.headRefName}) — ` +
694
+ `${pr.failingChecks.map((c) => c.name).join(", ")}.`,
695
+ );
696
+ fetchOrigin();
697
+
698
+ if (pr.mergeStateStatus === "BEHIND") {
699
+ const issueNumber = pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName);
700
+ const body = issueNumber !== null ? (ctx.issueBodies.get(issueNumber) ?? "") : "";
701
+ const mergedBlockerPrNumbers = getMergedBlockerPrNumbers(body, ctx.blockerStates);
702
+ if (mergedBlockerPrNumbers.length > 0) {
703
+ console.log(
704
+ `[phoebe] Behind main — catch-up merging blocker PR(s) ${mergedBlockerPrNumbers.map((n) => `#${n}`).join(", ")} before ${config.defaultBranch}.`,
705
+ );
706
+ } else {
707
+ console.log(`[phoebe] Behind main — catch-up merge for PR #${pr.prNumber}.`);
708
+ }
709
+
710
+ const cleanResult = tryCleanMerge(pr.headRefName, mergedBlockerPrNumbers);
711
+ if (cleanResult === "pushed") {
712
+ console.log(
713
+ `[phoebe] Catch-up merge for PR #${pr.prNumber} — pushed; waiting for CI on next cycle.`,
714
+ );
715
+ if (mergedBlockerPrNumbers.length > 0) {
716
+ postPrComment(pr.prNumber, stackedCatchUpRetractionComment(mergedBlockerPrNumbers));
717
+ }
718
+ return;
719
+ }
720
+ if (cleanResult === "conflicted" || cleanResult === "failed") {
721
+ console.log(
722
+ `[phoebe] Catch-up merge conflicted for PR #${pr.prNumber} — deferring to conflicts mode.`,
723
+ );
724
+ return;
725
+ }
726
+ }
727
+
728
+ await runChecksResolutionAgent(pr);
729
+ }
730
+
731
+ type GraphQLReviewThreadsPage = {
732
+ data: {
733
+ repository: {
734
+ pullRequest: {
735
+ reviewThreads: {
736
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
737
+ nodes: Array<{
738
+ isResolved: boolean;
739
+ isOutdated: boolean;
740
+ comments: {
741
+ nodes: Array<{
742
+ createdAt: string;
743
+ author: { login: string } | null;
744
+ }>;
745
+ };
746
+ }>;
747
+ };
748
+ };
749
+ };
750
+ };
751
+ };
752
+
753
+ function fetchReviewThreads(prNumber: PrNumber): ReviewThread[] {
754
+ const [owner, repo] = config.repoSlug.split("/");
755
+ const threads: ReviewThread[] = [];
756
+ let cursor: string | null = null;
757
+ let hasNextPage = true;
758
+
759
+ while (hasNextPage) {
760
+ const afterArg = cursor ? `, after:"${cursor}"` : "";
761
+ const query = `query($owner:String!,$repo:String!,$pr:Int!) {
762
+ repository(owner:$owner,name:$repo) {
763
+ pullRequest(number:$pr) {
764
+ reviewThreads(first:100${afterArg}) {
765
+ pageInfo { hasNextPage endCursor }
766
+ nodes {
767
+ isResolved
768
+ isOutdated
769
+ comments(first:30) {
770
+ nodes {
771
+ createdAt
772
+ author { login }
773
+ }
774
+ }
775
+ }
776
+ }
777
+ }
778
+ }
779
+ }`;
780
+ const page = JSON.parse(
781
+ execFileSync(
782
+ "gh",
783
+ [
784
+ "api",
785
+ "graphql",
786
+ "-f",
787
+ `query=${query}`,
788
+ "-f",
789
+ `owner=${owner}`,
790
+ "-f",
791
+ `repo=${repo}`,
792
+ "-F",
793
+ `pr=${prNumber}`,
794
+ ],
795
+ { encoding: "utf8", timeout: CHILD_PROCESS_TIMEOUT_MS },
796
+ ),
797
+ ) as GraphQLReviewThreadsPage;
798
+
799
+ const reviewThreads = page.data.repository.pullRequest.reviewThreads;
800
+ for (const node of reviewThreads.nodes) {
801
+ threads.push({
802
+ isResolved: node.isResolved,
803
+ isOutdated: node.isOutdated,
804
+ comments: node.comments.nodes.map((comment) => ({
805
+ createdAt: comment.createdAt,
806
+ authorLogin: comment.author?.login ?? "",
807
+ })),
808
+ });
809
+ }
810
+ hasNextPage = reviewThreads.pageInfo.hasNextPage;
811
+ cursor = reviewThreads.pageInfo.endCursor;
812
+ if (!hasNextPage) {
813
+ break;
814
+ }
815
+ }
816
+
817
+ return threads;
818
+ }
819
+
820
+ function hasNewReviewSummaryComment(
821
+ prNumber: PrNumber,
822
+ phoebeLogin: string,
823
+ since: string,
824
+ ): boolean {
825
+ const { comments } = ghJson<{
826
+ comments: Array<{ body: string; createdAt: string; author: { login: string } }>;
827
+ }>(["pr", "view", String(prNumber), "--json", "comments"]);
828
+ return comments.some(
829
+ (comment) =>
830
+ comment.author.login === phoebeLogin &&
831
+ comment.createdAt > since &&
832
+ isReviewSummaryComment(comment.body),
833
+ );
834
+ }
835
+
836
+ async function runReviewsResolutionAgent(pr: ReviewsCandidate, phoebeLogin: string): Promise<void> {
837
+ const runStartedAt = new Date().toISOString();
838
+ await runAgentWorkflow({
839
+ pr,
840
+ promptFile: config.promptFiles.reviews,
841
+ promptArgs: {
842
+ PR_NUMBER: String(pr.prNumber),
843
+ PR_BRANCH: pr.headRefName,
844
+ },
845
+ onResult: ({ worktreeDir, branch, originShaBefore, originShaAfter, localCommitCount }) => {
846
+ if (localCommitCount > 0) {
847
+ pushBranch(worktreeDir, branch);
848
+ console.log(`[phoebe] Review feedback handled for PR #${pr.prNumber} — pushed.`);
849
+ } else if (originShaAfter !== originShaBefore) {
850
+ console.log(
851
+ `[phoebe] Review feedback handled for PR #${pr.prNumber} — already pushed by agent.`,
852
+ );
853
+ }
854
+
855
+ const hasSummary = hasNewReviewSummaryComment(pr.prNumber, phoebeLogin, runStartedAt);
856
+ const pushed = localCommitCount > 0 || originShaAfter !== originShaBefore;
857
+ // Watermark only the activity captured before the agent ran (pr.threads is
858
+ // the pre-run snapshot from fetchReviewsWorkData). Re-fetching here could
859
+ // absorb feedback posted concurrently with the run — marking it handled
860
+ // even though the agent never observed it, so it would never trigger another
861
+ // cycle. Any activity newer than this snapshot correctly re-selects the PR.
862
+ const latestActivityAt = newestReviewThreadCommentCreatedAt(pr.threads);
863
+
864
+ if (hasSummary) {
865
+ console.log(`[phoebe] Review summary posted for PR #${pr.prNumber}.`);
866
+ } else if (!pushed) {
867
+ console.log(`[phoebe] Review handling for PR #${pr.prNumber} produced no summary or push.`);
868
+ }
869
+
870
+ postPrComment(
871
+ pr.prNumber,
872
+ buildReviewsHandledComment({
873
+ latestActivityAt,
874
+ failed: !hasSummary && !pushed,
875
+ }),
876
+ );
877
+ },
878
+ });
879
+ }
880
+
881
+ async function fixOnePrReviews(pr: ReviewsCandidate, phoebeLogin: string): Promise<void> {
882
+ console.log(`[phoebe] Reviews fix: PR #${pr.prNumber} (${pr.headRefName}).`);
883
+ fetchOrigin();
884
+ await runReviewsResolutionAgent(pr, phoebeLogin);
885
+ }
886
+
887
+ /**
888
+ * Work a single issue-shaped ticket: branch off the resolved base, run the
889
+ * given prompt, and — only when the agent left commits — push and open (or
890
+ * update) a PR. Shared by the `issues` and `research` kinds; the two differ
891
+ * only in `promptFile`. A research ticket that resolves as an issue-level
892
+ * artifact (comment + close + map update, done by the prompt) leaves no
893
+ * commits, so no PR is opened; one that produces a committed doc does.
894
+ */
895
+ async function runOneIssue(opts: {
896
+ issueNumber: number;
897
+ issueTitle: string;
898
+ worktreeBase: string;
899
+ stacked: boolean;
900
+ promptFile: string;
901
+ blockerIssueNumber?: number;
902
+ blockerPrNumber?: PrNumber;
903
+ }): Promise<void> {
904
+ const { issueNumber, issueTitle, worktreeBase, stacked, promptFile } = opts;
905
+ const { blockerIssueNumber, blockerPrNumber } = opts;
906
+ const agentBranch = issueBranch(issueNumber);
907
+
908
+ fetchOrigin();
909
+ const worktreeDir = prepareWorktree({ branch: agentBranch, baseRef: worktreeBase });
910
+ try {
911
+ runShellCommand(config.installCommand, worktreeDir);
912
+
913
+ await runAgentInWorktree({
914
+ worktreeDir,
915
+ promptFile,
916
+ promptArgs: { ISSUE_NUMBER: String(issueNumber) },
917
+ });
918
+
919
+ const newCommitCount = commitCount(worktreeDir, `${worktreeBase}..HEAD`);
920
+
921
+ if (newCommitCount > 0) {
922
+ pushBranch(worktreeDir, agentBranch);
923
+ const existingPrRow = ghJson<Array<{ number: number }>>([
924
+ "pr",
925
+ "list",
926
+ "--head",
927
+ agentBranch,
928
+ "--state",
929
+ "open",
930
+ "--json",
931
+ "number",
932
+ ])[0];
933
+ const existingPr = existingPrRow ? asPrNumber(existingPrRow.number) : undefined;
934
+ if (existingPr === undefined) {
935
+ const prTitle = `Phoebe: ${issueTitle} (#${issueNumber})`;
936
+ const prBody = buildInitialPrBody({
937
+ issueNumber,
938
+ commitCount: newCommitCount,
939
+ ...(stacked && blockerIssueNumber !== undefined && blockerPrNumber !== undefined
940
+ ? { stacked: { blockerIssueNumber, blockerPrNumber } }
941
+ : {}),
942
+ });
943
+ gh(
944
+ [
945
+ "pr",
946
+ "create",
947
+ "--head",
948
+ agentBranch,
949
+ "--base",
950
+ PR_BASE,
951
+ "--title",
952
+ prTitle,
953
+ "--body-file",
954
+ "-",
955
+ ],
956
+ { input: prBody },
957
+ );
958
+ } else {
959
+ console.log(
960
+ `[phoebe] PR #${existingPr} already exists for ${agentBranch} — posting follow-up note.`,
961
+ );
962
+ postPrComment(existingPr, followUpPrComment(issueNumber, newCommitCount));
963
+ }
964
+ } else {
965
+ console.log("[phoebe] No commits — skipping PR creation.");
966
+ }
967
+ } finally {
968
+ removeWorktree(repoDir, worktreeDir);
969
+ }
970
+ }
971
+
972
+ // ---------------------------------------------------------------------------
973
+ // Work kinds + cycle data
974
+ // ---------------------------------------------------------------------------
975
+
976
+ /**
977
+ * Everything a work-unit runner needs beyond the unit itself, assembled from the
978
+ * cycle's fetch results and passed into `runUnit` — so the runners hold no
979
+ * module-level state between selection and execution.
980
+ */
981
+ type RunContext = {
982
+ stack: StackContext;
983
+ phoebeLogin: string;
984
+ };
985
+
986
+ type WorkKind = {
987
+ name: WorkKindName;
988
+ fetch: () => Promise<WorkKindFetch>;
989
+ runUnit: (unit: WorkUnit["unit"], context: RunContext) => Promise<void>;
990
+ };
991
+
992
+ type WorkKindFetch =
993
+ | {
994
+ kind: "conflicts";
995
+ conflictingPrs: ConflictingPrCandidate[];
996
+ issueBodies: Map<number, string>;
997
+ currentMainHead: Sha;
998
+ }
999
+ | {
1000
+ kind: "checks";
1001
+ failingCheckPrs: ChecksCandidate[];
1002
+ issueBodies: Map<number, string>;
1003
+ }
1004
+ | {
1005
+ kind: "reviews";
1006
+ reviewActivityPrs: ReviewsCandidate[];
1007
+ issueBodies: Map<number, string>;
1008
+ phoebeLogin: string;
1009
+ }
1010
+ | { kind: "issues"; issues: Issue[]; blockerStates: Map<number, BlockerPrState> }
1011
+ | {
1012
+ kind: "research";
1013
+ researchIssues: Issue[];
1014
+ blockerStates: Map<number, BlockerPrState>;
1015
+ };
1016
+
1017
+ async function conflictingPrCandidate(pr: OpenPhoebePr): Promise<ConflictingPrCandidate | null> {
1018
+ for (let attempt = 0; attempt < MERGEABLE_RETRY_COUNT; attempt++) {
1019
+ const info = viewPrMergeInfo(pr.number);
1020
+ if (isPrMergeConflicting(info.mergeable, info.mergeStateStatus)) {
1021
+ const issueNumber = parseIssueNumberFromBranch(info.headRefName);
1022
+ return {
1023
+ prNumber: info.number,
1024
+ headRefName: info.headRefName,
1025
+ headSha: info.headRefOid,
1026
+ ...(issueNumber !== null ? { issueNumber } : {}),
1027
+ };
1028
+ }
1029
+ if (info.mergeable !== "UNKNOWN") {
1030
+ return null;
1031
+ }
1032
+ if (attempt < MERGEABLE_RETRY_COUNT - 1) {
1033
+ await sleep(MERGEABLE_RETRY_MS);
1034
+ }
1035
+ }
1036
+ return null;
1037
+ }
1038
+
1039
+ async function fetchConflictingPrs(): Promise<ConflictingPrCandidate[]> {
1040
+ const openPrs = listOpenPhoebePrs();
1041
+ const conflicting: ConflictingPrCandidate[] = [];
1042
+ for (const pr of openPrs) {
1043
+ try {
1044
+ const candidate = await conflictingPrCandidate(pr);
1045
+ if (candidate) {
1046
+ conflicting.push(candidate);
1047
+ }
1048
+ } catch (error) {
1049
+ console.warn(
1050
+ `[phoebe] Skipping PR #${pr.number} for conflicts this cycle — ${error instanceof Error ? error.message : String(error)}`,
1051
+ );
1052
+ }
1053
+ }
1054
+ return conflicting;
1055
+ }
1056
+
1057
+ // GraphQL statusCheckRollup is not readable by fine-grained PATs (GitHub-App/
1058
+ // OAuth only), so check state comes from the REST Actions API instead.
1059
+ function listCommitCheckItems(headSha: Sha): StatusCheckItem[] {
1060
+ return workflowRunsToCheckItems(
1061
+ ghJson<WorkflowRunItem[]>([
1062
+ "run",
1063
+ "list",
1064
+ "--commit",
1065
+ headSha,
1066
+ "--json",
1067
+ "workflowName,status,conclusion",
1068
+ "--limit",
1069
+ "50",
1070
+ ]),
1071
+ );
1072
+ }
1073
+
1074
+ async function failingChecksCandidate(pr: OpenPhoebePr): Promise<ChecksCandidate | null> {
1075
+ for (let attempt = 0; attempt < MERGEABLE_RETRY_COUNT; attempt++) {
1076
+ const info = viewPrMergeInfo(pr.number);
1077
+ if (isPrMergeConflicting(info.mergeable, info.mergeStateStatus)) {
1078
+ return null;
1079
+ }
1080
+ const checkItems = listCommitCheckItems(info.headRefOid);
1081
+ const rollup = statusCheckRollupState(checkItems);
1082
+ if (rollup === "FAILURE") {
1083
+ const issueNumber = parseIssueNumberFromBranch(info.headRefName);
1084
+ return {
1085
+ prNumber: info.number,
1086
+ headRefName: info.headRefName,
1087
+ headSha: info.headRefOid,
1088
+ mergeable: info.mergeable,
1089
+ mergeStateStatus: info.mergeStateStatus,
1090
+ failingChecks: listFailingChecks(checkItems),
1091
+ ...(issueNumber !== null ? { issueNumber } : {}),
1092
+ };
1093
+ }
1094
+ if (rollup !== "PENDING" && info.mergeable !== "UNKNOWN") {
1095
+ return null;
1096
+ }
1097
+ if (attempt < MERGEABLE_RETRY_COUNT - 1) {
1098
+ await sleep(MERGEABLE_RETRY_MS);
1099
+ }
1100
+ }
1101
+ return null;
1102
+ }
1103
+
1104
+ async function fetchFailingCheckPrs(): Promise<ChecksCandidate[]> {
1105
+ const openPrs = listOpenPhoebePrs();
1106
+ const failing: ChecksCandidate[] = [];
1107
+ for (const pr of openPrs) {
1108
+ try {
1109
+ const candidate = await failingChecksCandidate(pr);
1110
+ if (candidate) {
1111
+ failing.push(candidate);
1112
+ }
1113
+ } catch (error) {
1114
+ console.warn(
1115
+ `[phoebe] Skipping PR #${pr.number} for checks this cycle — ${error instanceof Error ? error.message : String(error)}`,
1116
+ );
1117
+ }
1118
+ }
1119
+ return failing;
1120
+ }
1121
+
1122
+ /**
1123
+ * Fetch the issue body behind every PR that maps to a Phoebe issue branch, keyed
1124
+ * by issue number. Dedupes so each issue is fetched once even when several PRs
1125
+ * share it. The stack selectors read these bodies for `blocked by` references.
1126
+ */
1127
+ function harvestIssueBodies(
1128
+ prs: ReadonlyArray<{ issueNumber?: number; headRefName: BranchRef }>,
1129
+ ): Map<number, string> {
1130
+ const issueNumbers = [
1131
+ ...new Set(
1132
+ prs
1133
+ .map((pr) => pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName))
1134
+ .filter((n): n is number => n !== null),
1135
+ ),
1136
+ ];
1137
+ return new Map(issueNumbers.map((number) => [number, issueBody(number)] as const));
1138
+ }
1139
+
1140
+ async function fetchReviewsWorkData(): Promise<{
1141
+ reviewActivityPrs: ReviewsCandidate[];
1142
+ issueBodies: Map<number, string>;
1143
+ phoebeLogin: string;
1144
+ }> {
1145
+ const phoebeLogin = phoebeGhLogin();
1146
+ const openPrs = listOpenPhoebePrs();
1147
+ const reviewActivityPrs: ReviewsCandidate[] = [];
1148
+
1149
+ for (const pr of openPrs) {
1150
+ try {
1151
+ const info = viewPrMergeInfo(pr.number);
1152
+ if (isPrMergeConflicting(info.mergeable, info.mergeStateStatus)) {
1153
+ continue;
1154
+ }
1155
+ const threads = fetchReviewThreads(pr.number);
1156
+ const issueNumber = parseIssueNumberFromBranch(info.headRefName);
1157
+ reviewActivityPrs.push({
1158
+ prNumber: info.number,
1159
+ headRefName: info.headRefName,
1160
+ authorLogin: pr.authorLogin,
1161
+ mergeable: info.mergeable,
1162
+ mergeStateStatus: info.mergeStateStatus,
1163
+ threads,
1164
+ handledWatermark: parseLatestMarker(
1165
+ fetchPrCommentBodies(pr.number),
1166
+ parseReviewsHandledWatermark,
1167
+ ),
1168
+ ...(issueNumber !== null ? { issueNumber } : {}),
1169
+ });
1170
+ } catch (error) {
1171
+ console.warn(
1172
+ `[phoebe] Skipping PR #${pr.number} for reviews this cycle — ${error instanceof Error ? error.message : String(error)}`,
1173
+ );
1174
+ }
1175
+ }
1176
+
1177
+ const issueBodies = harvestIssueBodies(reviewActivityPrs);
1178
+ return { reviewActivityPrs, issueBodies, phoebeLogin };
1179
+ }
1180
+
1181
+ async function fetchConflictWorkData(): Promise<{
1182
+ conflictingPrs: ConflictingPrCandidate[];
1183
+ issueBodies: Map<number, string>;
1184
+ currentMainHead: Sha;
1185
+ }> {
1186
+ const rawConflictingPrs = await fetchConflictingPrs();
1187
+ fetchOrigin();
1188
+ const currentMainHead = originBranchSha(defaultBranchRef);
1189
+ const conflictingPrs = rawConflictingPrs.map((pr) => ({
1190
+ ...pr,
1191
+ failureWatermark: parseLatestMarker(
1192
+ fetchPrCommentBodies(pr.prNumber),
1193
+ parseConflictFailWatermark,
1194
+ ),
1195
+ }));
1196
+ const issueBodies = harvestIssueBodies(conflictingPrs);
1197
+ return { conflictingPrs, issueBodies, currentMainHead };
1198
+ }
1199
+
1200
+ async function fetchChecksWorkData(): Promise<{
1201
+ failingCheckPrs: ChecksCandidate[];
1202
+ issueBodies: Map<number, string>;
1203
+ }> {
1204
+ const rawFailingPrs = await fetchFailingCheckPrs();
1205
+ const failingCheckPrs = rawFailingPrs.map((pr) => ({
1206
+ ...pr,
1207
+ failureWatermark: parseLatestMarker(
1208
+ fetchPrCommentBodies(pr.prNumber),
1209
+ parseChecksFailWatermark,
1210
+ ),
1211
+ }));
1212
+ const issueBodies = harvestIssueBodies(failingCheckPrs);
1213
+ return { failingCheckPrs, issueBodies };
1214
+ }
1215
+
1216
+ function fetchIssueWorkData(): { issues: Issue[]; blockerStates: Map<number, BlockerPrState> } {
1217
+ const issues = listReadyIssues();
1218
+ return { issues, blockerStates: buildBlockerStates(issues) };
1219
+ }
1220
+
1221
+ function fetchResearchWorkData(): {
1222
+ researchIssues: Issue[];
1223
+ blockerStates: Map<number, BlockerPrState>;
1224
+ } {
1225
+ const researchIssues = listResearchIssues();
1226
+ return { researchIssues, blockerStates: buildBlockerStates(researchIssues) };
1227
+ }
1228
+
1229
+ async function runIssueUnit(unit: IssueWorkUnit): Promise<void> {
1230
+ const { issue: target, resolution } = unit;
1231
+ console.log(
1232
+ `[phoebe] Working #${target.number} — base ${resolution.worktreeBase}` +
1233
+ (resolution.stacked ? ` (stacked on #${resolution.blockerIssueNumber})` : "") +
1234
+ ".",
1235
+ );
1236
+ await runOneIssue({
1237
+ issueNumber: target.number,
1238
+ issueTitle: target.title,
1239
+ worktreeBase: resolution.worktreeBase,
1240
+ stacked: resolution.stacked,
1241
+ promptFile: config.promptFiles.issue,
1242
+ blockerIssueNumber: resolution.blockerIssueNumber,
1243
+ blockerPrNumber: resolution.blockerPrNumber,
1244
+ });
1245
+ }
1246
+
1247
+ async function runResearchUnit(unit: IssueWorkUnit): Promise<void> {
1248
+ const { issue: target, resolution } = unit;
1249
+ console.log(
1250
+ `[phoebe] Researching #${target.number} — base ${resolution.worktreeBase}` +
1251
+ (resolution.stacked ? ` (stacked on #${resolution.blockerIssueNumber})` : "") +
1252
+ ".",
1253
+ );
1254
+ await runOneIssue({
1255
+ issueNumber: target.number,
1256
+ issueTitle: target.title,
1257
+ worktreeBase: resolution.worktreeBase,
1258
+ stacked: resolution.stacked,
1259
+ promptFile: config.promptFiles.research,
1260
+ blockerIssueNumber: resolution.blockerIssueNumber,
1261
+ blockerPrNumber: resolution.blockerPrNumber,
1262
+ });
1263
+ }
1264
+
1265
+ const KINDS: Record<WorkKindName, WorkKind> = {
1266
+ conflicts: {
1267
+ name: "conflicts",
1268
+ fetch: async () => {
1269
+ const { conflictingPrs, issueBodies, currentMainHead } = await fetchConflictWorkData();
1270
+ return { kind: "conflicts", conflictingPrs, issueBodies, currentMainHead };
1271
+ },
1272
+ runUnit: async (unit, context) => {
1273
+ await fixOnePrConflict(unit as ConflictingPrCandidate, context.stack);
1274
+ },
1275
+ },
1276
+ checks: {
1277
+ name: "checks",
1278
+ fetch: async () => {
1279
+ const { failingCheckPrs, issueBodies } = await fetchChecksWorkData();
1280
+ return { kind: "checks", failingCheckPrs, issueBodies };
1281
+ },
1282
+ runUnit: async (unit, context) => {
1283
+ await fixOnePrChecks(unit as ChecksCandidate, context.stack);
1284
+ },
1285
+ },
1286
+ reviews: {
1287
+ name: "reviews",
1288
+ fetch: async () => {
1289
+ const { reviewActivityPrs, issueBodies, phoebeLogin } = await fetchReviewsWorkData();
1290
+ return { kind: "reviews", reviewActivityPrs, issueBodies, phoebeLogin };
1291
+ },
1292
+ runUnit: async (unit, context) => {
1293
+ await fixOnePrReviews(unit as ReviewsCandidate, context.phoebeLogin);
1294
+ },
1295
+ },
1296
+ issues: {
1297
+ name: "issues",
1298
+ fetch: async () => {
1299
+ const { issues, blockerStates } = fetchIssueWorkData();
1300
+ return { kind: "issues", issues, blockerStates };
1301
+ },
1302
+ runUnit: async (unit) => {
1303
+ await runIssueUnit(unit as IssueWorkUnit);
1304
+ },
1305
+ },
1306
+ research: {
1307
+ name: "research",
1308
+ fetch: async () => {
1309
+ const { researchIssues, blockerStates } = fetchResearchWorkData();
1310
+ return { kind: "research", researchIssues, blockerStates };
1311
+ },
1312
+ runUnit: async (unit) => {
1313
+ await runResearchUnit(unit as IssueWorkUnit);
1314
+ },
1315
+ },
1316
+ };
1317
+
1318
+ type CycleWorkData = {
1319
+ issues: Issue[];
1320
+ researchIssues: Issue[];
1321
+ blockerStates: Map<number, BlockerPrState>;
1322
+ conflictingPrs: ConflictingPrCandidate[];
1323
+ failingCheckPrs: ChecksCandidate[];
1324
+ reviewActivityPrs: ReviewsCandidate[];
1325
+ issueBodies: Map<number, string>;
1326
+ phoebeLogin?: string;
1327
+ currentMainHead?: Sha;
1328
+ };
1329
+
1330
+ async function fetchCycleWorkData(kinds: readonly WorkKindName[]): Promise<CycleWorkData> {
1331
+ let issues: Issue[] = [];
1332
+ let researchIssues: Issue[] = [];
1333
+ let blockerStates = new Map<number, BlockerPrState>();
1334
+ let conflictingPrs: ConflictingPrCandidate[] = [];
1335
+ let failingCheckPrs: ChecksCandidate[] = [];
1336
+ let reviewActivityPrs: ReviewsCandidate[] = [];
1337
+ let issueBodies = new Map<number, string>();
1338
+ let phoebeLogin: string | undefined;
1339
+ let currentMainHead: Sha | undefined;
1340
+
1341
+ for (const kind of kinds) {
1342
+ const fetched = await KINDS[kind].fetch();
1343
+ if (fetched.kind === "issues") {
1344
+ issues = fetched.issues;
1345
+ for (const [number, state] of fetched.blockerStates) {
1346
+ blockerStates.set(number, state);
1347
+ }
1348
+ } else if (fetched.kind === "research") {
1349
+ researchIssues = fetched.researchIssues;
1350
+ for (const [number, state] of fetched.blockerStates) {
1351
+ blockerStates.set(number, state);
1352
+ }
1353
+ } else if (fetched.kind === "conflicts") {
1354
+ conflictingPrs = fetched.conflictingPrs;
1355
+ issueBodies = fetched.issueBodies;
1356
+ currentMainHead = fetched.currentMainHead;
1357
+ } else if (fetched.kind === "checks") {
1358
+ failingCheckPrs = fetched.failingCheckPrs;
1359
+ for (const [number, body] of fetched.issueBodies) {
1360
+ issueBodies.set(number, body);
1361
+ }
1362
+ } else {
1363
+ reviewActivityPrs = fetched.reviewActivityPrs;
1364
+ phoebeLogin = fetched.phoebeLogin;
1365
+ for (const [number, body] of fetched.issueBodies) {
1366
+ issueBodies.set(number, body);
1367
+ }
1368
+ }
1369
+ }
1370
+
1371
+ const allBodies = [...issueBodies.entries()].map(([number, body]) => ({ number, body }));
1372
+ if (allBodies.length > 0) {
1373
+ const mergedBlockerStates = buildBlockerStatesFromBodies(allBodies);
1374
+ for (const [blockerIssue, state] of mergedBlockerStates) {
1375
+ blockerStates.set(blockerIssue, state);
1376
+ }
1377
+ }
1378
+
1379
+ return {
1380
+ issues,
1381
+ researchIssues,
1382
+ blockerStates,
1383
+ conflictingPrs,
1384
+ failingCheckPrs,
1385
+ reviewActivityPrs,
1386
+ issueBodies,
1387
+ phoebeLogin,
1388
+ currentMainHead,
1389
+ };
1390
+ }
1391
+
1392
+ function logIdleCycle(data: CycleWorkData): void {
1393
+ const phoebeBase = process.env["PHOEBE_BASE"];
1394
+ if (data.issues.length > 0 && !selectIssue(data.issues, data.blockerStates, phoebeBase)) {
1395
+ console.log(
1396
+ `[phoebe] ${data.issues.length} ${config.readyLabel} issue(s) but none workable this cycle (blocked or waiting on blocker PR).`,
1397
+ );
1398
+ return;
1399
+ }
1400
+ if (
1401
+ data.researchIssues.length > 0 &&
1402
+ !selectIssue(data.researchIssues, data.blockerStates, phoebeBase)
1403
+ ) {
1404
+ console.log(
1405
+ `[phoebe] ${data.researchIssues.length} ${config.researchLabel} ticket(s) but none workable this cycle (blocked or waiting on blocker PR).`,
1406
+ );
1407
+ return;
1408
+ }
1409
+ const stack: StackContext = { issueBodies: data.issueBodies, blockerStates: data.blockerStates };
1410
+ if (data.conflictingPrs.length > 0) {
1411
+ const conflictOpts = data.currentMainHead
1412
+ ? { currentMainHead: data.currentMainHead }
1413
+ : undefined;
1414
+ const { unit, skippedStacked, skippedWatermark } = summarizeConflictSelection(
1415
+ data.conflictingPrs,
1416
+ stack,
1417
+ conflictOpts,
1418
+ );
1419
+ if (skippedStacked > 0) {
1420
+ console.log(
1421
+ `[phoebe] ${skippedStacked} conflicting PR(s) skipped (stacked on open blocker).`,
1422
+ );
1423
+ }
1424
+ if (skippedWatermark > 0) {
1425
+ console.log(
1426
+ `[phoebe] ${skippedWatermark} conflicting PR(s) skipped (unchanged failure watermark).`,
1427
+ );
1428
+ }
1429
+ if (!unit) {
1430
+ console.log(
1431
+ `[phoebe] ${data.conflictingPrs.length} conflicting PR(s) but none fixable this cycle.`,
1432
+ );
1433
+ return;
1434
+ }
1435
+ }
1436
+ if (data.failingCheckPrs.length > 0) {
1437
+ const { unit, skipped } = summarizeChecksSelection(data.failingCheckPrs, stack);
1438
+ if (skipped > 0) {
1439
+ console.log(
1440
+ `[phoebe] ${skipped} failing-CI PR(s) skipped (conflicting, stacked, or watermarked).`,
1441
+ );
1442
+ }
1443
+ if (!unit) {
1444
+ console.log(
1445
+ `[phoebe] ${data.failingCheckPrs.length} failing-CI PR(s) but none fixable this cycle.`,
1446
+ );
1447
+ return;
1448
+ }
1449
+ }
1450
+ if (data.reviewActivityPrs.length > 0 && data.phoebeLogin) {
1451
+ const { unit, skipped } = summarizeReviewsSelection(
1452
+ data.reviewActivityPrs,
1453
+ stack,
1454
+ data.phoebeLogin,
1455
+ );
1456
+ if (skipped > 0) {
1457
+ console.log(
1458
+ `[phoebe] ${skipped} review-feedback PR(s) skipped (stacked, watermarked, or no new activity).`,
1459
+ );
1460
+ }
1461
+ if (!unit) {
1462
+ console.log(
1463
+ `[phoebe] ${data.reviewActivityPrs.length} review-feedback PR(s) but none fixable this cycle.`,
1464
+ );
1465
+ return;
1466
+ }
1467
+ }
1468
+ console.log("[phoebe] No work this cycle — idle.");
1469
+ }
1470
+
1471
+ function sleep(ms: number): Promise<void> {
1472
+ return new Promise((resolve) => setTimeout(resolve, ms));
1473
+ }
1474
+
1475
+ function describeUnit(picked: WorkUnit): string {
1476
+ if (picked.kind === "conflicts") {
1477
+ const unit = picked.unit;
1478
+ return `conflict fix for PR #${unit.prNumber} (${unit.headRefName})`;
1479
+ }
1480
+ if (picked.kind === "checks") {
1481
+ const unit = picked.unit;
1482
+ return `checks fix for PR #${unit.prNumber} (${unit.headRefName})`;
1483
+ }
1484
+ if (picked.kind === "reviews") {
1485
+ const unit = picked.unit;
1486
+ return `review feedback for PR #${unit.prNumber} (${unit.headRefName})`;
1487
+ }
1488
+ if (picked.kind === "research") {
1489
+ const unit = picked.unit;
1490
+ return `research ticket #${unit.issue.number} — base ${unit.resolution.worktreeBase}`;
1491
+ }
1492
+ const unit = picked.unit;
1493
+ return `issue #${unit.issue.number} — base ${unit.resolution.worktreeBase}`;
1494
+ }
1495
+
1496
+ // ---------------------------------------------------------------------------
1497
+ // Main loop
1498
+ // ---------------------------------------------------------------------------
1499
+
1500
+ /**
1501
+ * Drive the Phoebe worker loop until it exits (persistent mode) or completes
1502
+ * one unit (`--run-once`). Called by src/cli.ts after the resolved config is
1503
+ * installed; the CLI passes its argv with `--config <path>` already stripped
1504
+ * so this only sees engine-level flags.
1505
+ */
1506
+ export async function runEngine(argv: readonly string[] = process.argv.slice(2)): Promise<void> {
1507
+ const runOnce = argv.includes("--run-once");
1508
+ const dryRun = argv.includes("--dry-run");
1509
+ const rawPollIntervalMs = Number(process.env["PHOEBE_POLL_INTERVAL_MS"]);
1510
+ const pollIntervalMs =
1511
+ Number.isFinite(rawPollIntervalMs) && rawPollIntervalMs > 0
1512
+ ? rawPollIntervalMs
1513
+ : DEFAULT_POLL_INTERVAL_MS;
1514
+
1515
+ console.log(
1516
+ runOnce
1517
+ ? "[phoebe] Run-once mode — will work at most one unit of the first one-shot-eligible kind in WORK_ORDER, then exit."
1518
+ : `[phoebe] Persistent mode — idle poll every ${pollIntervalMs}ms. SIGTERM drains: finish the current unit, then exit 0.`,
1519
+ );
1520
+ if (dryRun) {
1521
+ console.log("[phoebe] Dry-run — selection only, nothing executes.");
1522
+ }
1523
+
1524
+ // Bootstrap the private clone every work unit fetches/worktrees against. Only
1525
+ // in the container (on the host repoDir is the cwd, already a repo) and never
1526
+ // for --dry-run (selection uses the GitHub API, not a local clone). No-op once
1527
+ // the clone exists, so it's safe on every daemon restart.
1528
+ if (inContainer && !dryRun) {
1529
+ ensureClone({ repoUrl: config.repoUrl, repoDir });
1530
+ }
1531
+
1532
+ // `phoebe boot` stops the engine with SIGTERM (container shutdown, and later a
1533
+ // config/ref change). Drain gracefully rather than dying mid-unit: finish the
1534
+ // unit in flight, start no new one, then return (exit 0). The wait below wakes
1535
+ // early on drain so an idle poll-sleep does not stall shutdown.
1536
+ const drain = installDrainSignal();
1537
+ try {
1538
+ await runLoop({ runOnce, dryRun, pollIntervalMs, drain });
1539
+ } finally {
1540
+ drain.dispose();
1541
+ }
1542
+ }
1543
+
1544
+ async function runLoop({
1545
+ runOnce,
1546
+ dryRun,
1547
+ pollIntervalMs,
1548
+ drain,
1549
+ }: {
1550
+ runOnce: boolean;
1551
+ dryRun: boolean;
1552
+ pollIntervalMs: number;
1553
+ drain: DrainSignal;
1554
+ }): Promise<void> {
1555
+ while (true) {
1556
+ if (drain.requested) {
1557
+ console.log("[phoebe] Drain requested — starting no new work unit; exiting 0.");
1558
+ break;
1559
+ }
1560
+ const fetchKinds = runOnce ? oneShotWorkKinds(workOrder) : workOrder;
1561
+ const data = await fetchCycleWorkData(fetchKinds);
1562
+ const picked = selectFirstWorkUnit(
1563
+ workOrder,
1564
+ {
1565
+ issues: data.issues,
1566
+ researchIssues: data.researchIssues,
1567
+ blockerStates: data.blockerStates,
1568
+ conflictingPrs: data.conflictingPrs,
1569
+ failingCheckPrs: data.failingCheckPrs,
1570
+ reviewActivityPrs: data.reviewActivityPrs,
1571
+ issueBodies: data.issueBodies,
1572
+ phoebeBase: process.env["PHOEBE_BASE"],
1573
+ phoebeLogin: data.phoebeLogin,
1574
+ currentMainHead: data.currentMainHead,
1575
+ },
1576
+ { oneShotOnly: runOnce },
1577
+ );
1578
+
1579
+ if (!picked) {
1580
+ if (runOnce) {
1581
+ console.log(RUN_ONCE_NOTHING_MESSAGE);
1582
+ } else {
1583
+ logIdleCycle(data);
1584
+ }
1585
+ if (runOnce || dryRun) break;
1586
+ // Interruptible idle poll — a SIGTERM mid-sleep wakes it, the next
1587
+ // iteration's drain check breaks, and shutdown does not wait a full cycle.
1588
+ await drain.wait(pollIntervalMs);
1589
+ continue;
1590
+ }
1591
+
1592
+ // A drain that arrived during the fetch/selection above must not let this
1593
+ // freshly-picked unit start — "start no new one". The in-flight unit (if any)
1594
+ // already finished before we looped back here, so exit now.
1595
+ if (drain.requested) {
1596
+ console.log("[phoebe] Drain requested before starting the next unit — exiting 0.");
1597
+ break;
1598
+ }
1599
+
1600
+ const decision = executionDecision({ dryRun, inContainer });
1601
+ if (decision === "dry-run") {
1602
+ console.log(`[phoebe] Would execute: ${describeUnit(picked)}.`);
1603
+ break;
1604
+ }
1605
+ if (decision === "refuse") {
1606
+ console.error(EXECUTION_REFUSED_MESSAGE);
1607
+ process.exit(1);
1608
+ }
1609
+
1610
+ try {
1611
+ await KINDS[picked.kind].runUnit(picked.unit, {
1612
+ stack: { issueBodies: data.issueBodies, blockerStates: data.blockerStates },
1613
+ phoebeLogin: data.phoebeLogin ?? "",
1614
+ });
1615
+ } catch (error) {
1616
+ if (runOnce) {
1617
+ throw error;
1618
+ }
1619
+ // A failed unit must not kill the daemon — prepareWorktree clears any
1620
+ // stale worktree on the next attempt.
1621
+ console.error(
1622
+ `[phoebe] Failed executing ${describeUnit(picked)} — ${error instanceof Error ? error.message : String(error)}`,
1623
+ );
1624
+ await drain.wait(pollIntervalMs);
1625
+ continue;
1626
+ }
1627
+
1628
+ if (runOnce) break;
1629
+ // Drain requested while the unit ran: it is finished, so exit now rather
1630
+ // than picking up another. This is the graceful-drain boundary.
1631
+ if (drain.requested) {
1632
+ console.log("[phoebe] Finished the in-flight unit under drain — exiting 0.");
1633
+ break;
1634
+ }
1635
+ }
1636
+ }