phoebe-agent 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/dist/phoebe.config.d.ts +2 -0
- package/dist/phoebe.config.js +43 -0
- package/dist/src/agent-env.d.ts +6 -0
- package/dist/src/agent-env.js +24 -0
- package/dist/src/cli.d.ts +25 -0
- package/dist/src/cli.js +161 -0
- package/dist/src/config-schema.d.ts +163 -0
- package/dist/src/config-schema.js +140 -0
- package/dist/src/execution-gate.d.ts +9 -0
- package/dist/src/execution-gate.js +17 -0
- package/dist/src/git-model.d.ts +30 -0
- package/dist/src/git-model.js +71 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +12 -0
- package/dist/src/init.d.ts +82 -0
- package/dist/src/init.js +207 -0
- package/dist/src/load-config.d.ts +88 -0
- package/dist/src/load-config.js +153 -0
- package/dist/src/main.d.ts +7 -0
- package/dist/src/main.js +1180 -0
- package/dist/src/orchestrator.d.ts +275 -0
- package/dist/src/orchestrator.js +579 -0
- package/dist/src/prompt.d.ts +13 -0
- package/dist/src/prompt.js +55 -0
- package/dist/src/providers/providers.d.ts +3 -0
- package/dist/src/providers/providers.js +210 -0
- package/dist/src/providers/run-agent.d.ts +34 -0
- package/dist/src/providers/run-agent.js +57 -0
- package/dist/src/providers/types.d.ts +27 -0
- package/dist/src/providers/types.js +6 -0
- package/dist/src/resolved-config.d.ts +8 -0
- package/dist/src/resolved-config.js +49 -0
- package/dist/src/supervisor-decision.d.ts +70 -0
- package/dist/src/supervisor-decision.js +94 -0
- package/package.json +53 -0
- package/prompts/checks-prompt.md +53 -0
- package/prompts/conflict-prompt.md +53 -0
- package/prompts/prompt.md +55 -0
- package/prompts/reviews-prompt.md +106 -0
- package/templates/.env.example +21 -0
- package/templates/container/Dockerfile +54 -0
- package/templates/container/compose.daemon.yml +16 -0
- package/templates/container/compose.yml +46 -0
- package/templates/container/supervisor.sh +50 -0
- package/templates/phoebe.config.ts +15 -0
package/dist/src/main.js
ADDED
|
@@ -0,0 +1,1180 @@
|
|
|
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
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { basename, dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { config } from "./resolved-config.js";
|
|
24
|
+
import { PROVIDER_NAMES } from "./config-schema.js";
|
|
25
|
+
import { buildAgentEnv } from "./agent-env.js";
|
|
26
|
+
import { EXECUTION_REFUSED_MESSAGE, executionDecision, isInsideContainer, } from "./execution-gate.js";
|
|
27
|
+
import { addWorktreeForExistingBranch, addWorktreeForNewBranch, commitCount, fetchOrigin as gitFetchOrigin, originBranchSha as gitOriginBranchSha, pushBranch, removeWorktree, worktreeDirForBranch, } from "./git-model.js";
|
|
28
|
+
import { PROVIDERS } from "./providers/providers.js";
|
|
29
|
+
import { runAgent } from "./providers/run-agent.js";
|
|
30
|
+
import { buildDefaultPromptArgs, renderPrompt } from "./prompt.js";
|
|
31
|
+
import { SELF_UPDATE_EXIT_CODE, shouldExitForSelfUpdate } from "./supervisor-decision.js";
|
|
32
|
+
import { buildInitialPrBody, buildReviewsHandledComment, checksFixFailureComment, conflictFixFailureComment, followUpPrComment, formatFailingChecksForPrompt, isReviewSummaryComment, issueBranch, isPrInScope, isPrMergeConflicting, listFailingChecks, newestReviewThreadCommentCreatedAt, parseBlockedBy, parseChecksFailWatermarkFromComments, parseConflictFailWatermarkFromComments, parseReviewsHandledWatermarkFromComments, parseIssueNumberFromBranch, getMergedBlockerPrNumbers, oneShotWorkKinds, selectChecksCandidates, selectChecksUnit, selectReviewsCandidates, selectReviewsUnit, stackedCatchUpRetractionComment, RUN_ONCE_NOTHING_MESSAGE, selectConflictFixCandidates, selectFirstWorkUnit, selectConflictUnit, selectIssue, shouldPostChecksFixFailure, shouldPostConflictFixFailure, statusCheckRollupState, validateWorkOrder, workflowRunsToCheckItems, } from "./orchestrator.js";
|
|
33
|
+
const DEFAULT_POLL_INTERVAL_MS = 300_000;
|
|
34
|
+
// Never let a gh/git child process block the persistent loop forever (rate-limit
|
|
35
|
+
// backoff, credential prompt, network partition). Configured toolchain commands
|
|
36
|
+
// (install/test) get a longer leash.
|
|
37
|
+
const CHILD_PROCESS_TIMEOUT_MS = 120_000;
|
|
38
|
+
const SHELL_COMMAND_TIMEOUT_MS = 600_000;
|
|
39
|
+
const MERGEABLE_RETRY_MS = 5_000;
|
|
40
|
+
const MERGEABLE_RETRY_COUNT = 3;
|
|
41
|
+
const PR_BASE = config.defaultBranch;
|
|
42
|
+
// The branch the supervisor keeps the clone on — normally the default branch,
|
|
43
|
+
// overridable (matching container/supervisor.sh) so a not-yet-merged Phoebe
|
|
44
|
+
// branch can run itself end-to-end. PRs and worktree bases still use
|
|
45
|
+
// config.defaultBranch.
|
|
46
|
+
const trackedBranch = process.env["PHOEBE_DEFAULT_BRANCH"] ?? config.defaultBranch;
|
|
47
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
// Resolve a package-root-relative resource by walking up from this module's
|
|
49
|
+
// directory: the build emits dist/src/main.js while prompts/ and templates/
|
|
50
|
+
// ship at the package root, so the depth back to the root differs between the
|
|
51
|
+
// source layout (src/) and the built layout (dist/src/).
|
|
52
|
+
function resolvePackageFile(relativePath) {
|
|
53
|
+
let dir = moduleDir;
|
|
54
|
+
while (true) {
|
|
55
|
+
const candidate = join(dir, relativePath);
|
|
56
|
+
if (existsSync(candidate)) {
|
|
57
|
+
return candidate;
|
|
58
|
+
}
|
|
59
|
+
const parent = dirname(dir);
|
|
60
|
+
// Stop at the package boundary. When Phoebe is installed as a dependency,
|
|
61
|
+
// its package sits directly inside a `node_modules` directory; walking past
|
|
62
|
+
// it would escape into the consuming repo, where an unrelated resource of
|
|
63
|
+
// the same relative path could shadow a genuinely-missing packaged one.
|
|
64
|
+
if (parent === dir || basename(parent) === "node_modules") {
|
|
65
|
+
throw new Error(`Could not find ${relativePath} within the Phoebe package (searched from ${moduleDir})`);
|
|
66
|
+
}
|
|
67
|
+
dir = parent;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const inContainer = isInsideContainer();
|
|
71
|
+
// On the host only selection/--dry-run runs, against the local checkout; in
|
|
72
|
+
// the container all git state lives in the private clone on the named volume.
|
|
73
|
+
const repoDir = inContainer ? config.paths.repoDir : process.cwd();
|
|
74
|
+
const worktreesDir = config.paths.worktreesDir;
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Provider selection (multi-provider ready)
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
function selectProvider() {
|
|
79
|
+
const name = process.env["PHOEBE_AGENT"] ?? config.defaultProvider;
|
|
80
|
+
if (!PROVIDER_NAMES.includes(name)) {
|
|
81
|
+
throw new Error(`Unknown PHOEBE_AGENT "${name}". Use one of: ${PROVIDER_NAMES.join(", ")}.`);
|
|
82
|
+
}
|
|
83
|
+
const provider = PROVIDERS[name];
|
|
84
|
+
const model = process.env["PHOEBE_MODEL"] ?? config.defaultModels[name];
|
|
85
|
+
return { provider, model };
|
|
86
|
+
}
|
|
87
|
+
const workOrder = validateWorkOrder(config.workOrder);
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// gh helpers — always pinned to the configured repo
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
function ghJson(args) {
|
|
92
|
+
return JSON.parse(execFileSync("gh", [...args, "-R", config.repoSlug], {
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
timeout: CHILD_PROCESS_TIMEOUT_MS,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
function ghApiJson(endpoint) {
|
|
98
|
+
return JSON.parse(execFileSync("gh", ["api", endpoint], {
|
|
99
|
+
encoding: "utf8",
|
|
100
|
+
timeout: CHILD_PROCESS_TIMEOUT_MS,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
function gh(args, opts) {
|
|
104
|
+
execFileSync("gh", [...args, "-R", config.repoSlug], {
|
|
105
|
+
stdio: opts?.input !== undefined ? ["pipe", "inherit", "inherit"] : "inherit",
|
|
106
|
+
timeout: CHILD_PROCESS_TIMEOUT_MS,
|
|
107
|
+
...(opts?.input !== undefined ? { input: opts.input } : {}),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function listReadyIssues() {
|
|
111
|
+
return ghJson([
|
|
112
|
+
"issue",
|
|
113
|
+
"list",
|
|
114
|
+
"--state",
|
|
115
|
+
"open",
|
|
116
|
+
"--label",
|
|
117
|
+
config.readyLabel,
|
|
118
|
+
"--limit",
|
|
119
|
+
"100",
|
|
120
|
+
"--search",
|
|
121
|
+
"sort:created-asc",
|
|
122
|
+
"--json",
|
|
123
|
+
"number,title,body,labels,createdAt",
|
|
124
|
+
]).map((row) => ({
|
|
125
|
+
number: row.number,
|
|
126
|
+
title: row.title,
|
|
127
|
+
body: row.body,
|
|
128
|
+
createdAt: row.createdAt,
|
|
129
|
+
labels: row.labels.map((l) => l.name),
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
function blockerPrState(blockerIssueNumber) {
|
|
133
|
+
const branch = issueBranch(blockerIssueNumber);
|
|
134
|
+
const open = ghJson([
|
|
135
|
+
"pr",
|
|
136
|
+
"list",
|
|
137
|
+
"--head",
|
|
138
|
+
branch,
|
|
139
|
+
"--state",
|
|
140
|
+
"open",
|
|
141
|
+
"--json",
|
|
142
|
+
"number",
|
|
143
|
+
"--limit",
|
|
144
|
+
"1",
|
|
145
|
+
]);
|
|
146
|
+
const merged = ghJson([
|
|
147
|
+
"pr",
|
|
148
|
+
"list",
|
|
149
|
+
"--head",
|
|
150
|
+
branch,
|
|
151
|
+
"--state",
|
|
152
|
+
"merged",
|
|
153
|
+
"--json",
|
|
154
|
+
"number",
|
|
155
|
+
"--limit",
|
|
156
|
+
"1",
|
|
157
|
+
]);
|
|
158
|
+
return {
|
|
159
|
+
hasOpenPr: open.length > 0,
|
|
160
|
+
openPrNumber: open[0]?.number,
|
|
161
|
+
hasMergedPr: merged.length > 0,
|
|
162
|
+
mergedPrNumber: merged[0]?.number,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function buildBlockerStates(issues) {
|
|
166
|
+
const blockerNumbers = new Set();
|
|
167
|
+
for (const issue of issues) {
|
|
168
|
+
for (const n of parseBlockedBy(issue.body)) {
|
|
169
|
+
blockerNumbers.add(n);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const states = new Map();
|
|
173
|
+
for (const n of blockerNumbers) {
|
|
174
|
+
try {
|
|
175
|
+
states.set(n, blockerPrState(n));
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
// Absent entries are treated as unmerged blockers — safe to retry next cycle.
|
|
179
|
+
console.warn(`[phoebe] Skipping blocker state for #${n} this cycle — ${error instanceof Error ? error.message : String(error)}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return states;
|
|
183
|
+
}
|
|
184
|
+
function buildBlockerStatesFromBodies(bodies) {
|
|
185
|
+
return buildBlockerStates(bodies.map(({ number, body }) => ({
|
|
186
|
+
number,
|
|
187
|
+
title: "",
|
|
188
|
+
body,
|
|
189
|
+
labels: [],
|
|
190
|
+
createdAt: "",
|
|
191
|
+
})));
|
|
192
|
+
}
|
|
193
|
+
function postPrComment(prNumber, body) {
|
|
194
|
+
gh(["pr", "comment", String(prNumber), "--body", body]);
|
|
195
|
+
}
|
|
196
|
+
function listOpenPhoebePrs() {
|
|
197
|
+
return ghJson([
|
|
198
|
+
"pr",
|
|
199
|
+
"list",
|
|
200
|
+
"--base",
|
|
201
|
+
PR_BASE,
|
|
202
|
+
"--state",
|
|
203
|
+
"open",
|
|
204
|
+
"--json",
|
|
205
|
+
"number,headRefName,isDraft,isCrossRepository,labels,author",
|
|
206
|
+
"--limit",
|
|
207
|
+
"100",
|
|
208
|
+
])
|
|
209
|
+
.filter((pr) => isPrInScope({
|
|
210
|
+
headRefName: pr.headRefName,
|
|
211
|
+
isDraft: pr.isDraft,
|
|
212
|
+
isCrossRepository: pr.isCrossRepository,
|
|
213
|
+
labels: pr.labels.map((label) => label.name),
|
|
214
|
+
}))
|
|
215
|
+
.map((pr) => ({
|
|
216
|
+
number: pr.number,
|
|
217
|
+
headRefName: pr.headRefName,
|
|
218
|
+
authorLogin: pr.author.login,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
function viewPrMergeInfo(prNumber) {
|
|
222
|
+
return ghJson([
|
|
223
|
+
"pr",
|
|
224
|
+
"view",
|
|
225
|
+
String(prNumber),
|
|
226
|
+
"--json",
|
|
227
|
+
"number,headRefName,headRefOid,mergeable,mergeStateStatus",
|
|
228
|
+
]);
|
|
229
|
+
}
|
|
230
|
+
function prConflictFailWatermark(prNumber) {
|
|
231
|
+
const { comments } = ghJson([
|
|
232
|
+
"pr",
|
|
233
|
+
"view",
|
|
234
|
+
String(prNumber),
|
|
235
|
+
"--json",
|
|
236
|
+
"comments",
|
|
237
|
+
]);
|
|
238
|
+
return parseConflictFailWatermarkFromComments(comments.map((comment) => comment.body));
|
|
239
|
+
}
|
|
240
|
+
function prChecksFailWatermark(prNumber) {
|
|
241
|
+
const { comments } = ghJson([
|
|
242
|
+
"pr",
|
|
243
|
+
"view",
|
|
244
|
+
String(prNumber),
|
|
245
|
+
"--json",
|
|
246
|
+
"comments",
|
|
247
|
+
]);
|
|
248
|
+
return parseChecksFailWatermarkFromComments(comments.map((comment) => comment.body));
|
|
249
|
+
}
|
|
250
|
+
function prReviewsHandledWatermark(prNumber) {
|
|
251
|
+
const { comments } = ghJson([
|
|
252
|
+
"pr",
|
|
253
|
+
"view",
|
|
254
|
+
String(prNumber),
|
|
255
|
+
"--json",
|
|
256
|
+
"comments",
|
|
257
|
+
]);
|
|
258
|
+
return parseReviewsHandledWatermarkFromComments(comments.map((comment) => comment.body));
|
|
259
|
+
}
|
|
260
|
+
function phoebeGhLogin() {
|
|
261
|
+
return ghApiJson("user").login;
|
|
262
|
+
}
|
|
263
|
+
function issueBody(issueNumber) {
|
|
264
|
+
return ghJson(["issue", "view", String(issueNumber), "--json", "body"]).body;
|
|
265
|
+
}
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// git helpers bound to the clone
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
function fetchOrigin() {
|
|
270
|
+
gitFetchOrigin(repoDir);
|
|
271
|
+
}
|
|
272
|
+
function originBranchSha(branch) {
|
|
273
|
+
return gitOriginBranchSha(repoDir, branch);
|
|
274
|
+
}
|
|
275
|
+
function currentConflictFailureWatermark(branch) {
|
|
276
|
+
fetchOrigin();
|
|
277
|
+
return {
|
|
278
|
+
prHead: originBranchSha(branch),
|
|
279
|
+
mainHead: originBranchSha(config.defaultBranch),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function currentChecksFailureWatermark(branch) {
|
|
283
|
+
fetchOrigin();
|
|
284
|
+
return { prHead: originBranchSha(branch) };
|
|
285
|
+
}
|
|
286
|
+
function gitInWorktree(worktreeDir, args, opts) {
|
|
287
|
+
return execFileSync("git", ["-C", worktreeDir, ...args], {
|
|
288
|
+
encoding: "utf8",
|
|
289
|
+
timeout: CHILD_PROCESS_TIMEOUT_MS,
|
|
290
|
+
...(opts?.stdio ? { stdio: opts.stdio } : {}),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/** Run a configured toolchain command (a shell string) inside a worktree. */
|
|
294
|
+
function runShellCommand(command, cwd) {
|
|
295
|
+
execSync(command, { cwd, stdio: "inherit", timeout: SHELL_COMMAND_TIMEOUT_MS });
|
|
296
|
+
}
|
|
297
|
+
/** Shell executor for prompt !`...` expansion — captures stdout. */
|
|
298
|
+
function promptShell(cwd) {
|
|
299
|
+
return (command) => execSync(command, { cwd, encoding: "utf8", timeout: SHELL_COMMAND_TIMEOUT_MS });
|
|
300
|
+
}
|
|
301
|
+
function loadPromptTemplate(relativePath) {
|
|
302
|
+
return readFileSync(resolvePackageFile(relativePath), "utf8");
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* After each cycle's fetch, exit deliberately when Phoebe's own code changed
|
|
306
|
+
* on the default branch — the container supervisor reinstalls and re-execs.
|
|
307
|
+
*
|
|
308
|
+
* When the supervisor has pinned us to the last-good SHA because a freshly
|
|
309
|
+
* pulled commit was crash-looping, it passes that quarantined SHA in
|
|
310
|
+
* `PHOEBE_QUARANTINED_SHA`; we stay on the good code (no self-update) while
|
|
311
|
+
* `origin/<branch>` still points at it, and resume self-updating only once the
|
|
312
|
+
* branch advances past it (a fix landed).
|
|
313
|
+
*/
|
|
314
|
+
function exitForSelfUpdateIfNeeded() {
|
|
315
|
+
if (!inContainer)
|
|
316
|
+
return;
|
|
317
|
+
fetchOrigin();
|
|
318
|
+
const originSha = originBranchSha(trackedBranch);
|
|
319
|
+
const changed = gitInWorktree(repoDir, ["diff", "--name-only", `HEAD..origin/${trackedBranch}`])
|
|
320
|
+
.split("\n")
|
|
321
|
+
.filter(Boolean);
|
|
322
|
+
if (shouldExitForSelfUpdate({
|
|
323
|
+
changedFiles: changed,
|
|
324
|
+
selfUpdatePaths: config.selfUpdatePaths,
|
|
325
|
+
originSha,
|
|
326
|
+
quarantinedSha: process.env["PHOEBE_QUARANTINED_SHA"] || null,
|
|
327
|
+
})) {
|
|
328
|
+
console.log(`[phoebe] Own code changed on origin/${trackedBranch} — exiting for supervisor re-exec.`);
|
|
329
|
+
process.exit(SELF_UPDATE_EXIT_CODE);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Work-unit execution
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
function prepareWorktree(opts) {
|
|
336
|
+
const worktreeDir = worktreeDirForBranch(worktreesDir, opts.branch);
|
|
337
|
+
removeWorktree(repoDir, worktreeDir);
|
|
338
|
+
if (opts.baseRef) {
|
|
339
|
+
addWorktreeForNewBranch({
|
|
340
|
+
repoDir,
|
|
341
|
+
worktreeDir,
|
|
342
|
+
branch: opts.branch,
|
|
343
|
+
baseRef: opts.baseRef,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
addWorktreeForExistingBranch({ repoDir, worktreeDir, branch: opts.branch });
|
|
348
|
+
}
|
|
349
|
+
return worktreeDir;
|
|
350
|
+
}
|
|
351
|
+
async function runAgentInWorktree(opts) {
|
|
352
|
+
const { provider, model } = selectProvider();
|
|
353
|
+
// Caller-supplied per-callsite args (ISSUE_NUMBER, PR_NUMBER, …) override
|
|
354
|
+
// the standard config-derived set by key.
|
|
355
|
+
const prompt = renderPrompt(loadPromptTemplate(opts.promptFile), { ...buildDefaultPromptArgs(config), ...opts.promptArgs }, promptShell(opts.worktreeDir));
|
|
356
|
+
const env = buildAgentEnv({
|
|
357
|
+
parentEnv: process.env,
|
|
358
|
+
provider: provider.name,
|
|
359
|
+
providerEnv: config.providerEnv,
|
|
360
|
+
});
|
|
361
|
+
const { exitCode } = await runAgent({
|
|
362
|
+
provider,
|
|
363
|
+
model,
|
|
364
|
+
prompt,
|
|
365
|
+
cwd: opts.worktreeDir,
|
|
366
|
+
env,
|
|
367
|
+
});
|
|
368
|
+
if (exitCode !== 0) {
|
|
369
|
+
console.log(`[phoebe] Agent exited with code ${exitCode}.`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function tryCleanMerge(branch, mergedBlockerPrNumbers = []) {
|
|
373
|
+
let worktreeDir;
|
|
374
|
+
try {
|
|
375
|
+
worktreeDir = prepareWorktree({ branch });
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return "failed";
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
for (const blockerPrNumber of mergedBlockerPrNumbers) {
|
|
382
|
+
gitInWorktree(worktreeDir, ["fetch", "origin", `pull/${blockerPrNumber}/head`], {
|
|
383
|
+
stdio: "inherit",
|
|
384
|
+
});
|
|
385
|
+
gitInWorktree(worktreeDir, ["merge", "FETCH_HEAD"], { stdio: "pipe" });
|
|
386
|
+
}
|
|
387
|
+
gitInWorktree(worktreeDir, ["fetch", "origin", config.defaultBranch], { stdio: "inherit" });
|
|
388
|
+
gitInWorktree(worktreeDir, ["merge", `origin/${config.defaultBranch}`], { stdio: "pipe" });
|
|
389
|
+
pushBranch(worktreeDir, branch);
|
|
390
|
+
removeWorktree(repoDir, worktreeDir);
|
|
391
|
+
return "pushed";
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
try {
|
|
395
|
+
const unmerged = gitInWorktree(worktreeDir, ["diff", "--name-only", "--diff-filter=U"]);
|
|
396
|
+
if (unmerged.trim()) {
|
|
397
|
+
gitInWorktree(worktreeDir, ["merge", "--abort"], { stdio: "ignore" });
|
|
398
|
+
removeWorktree(repoDir, worktreeDir);
|
|
399
|
+
return "needs_agent";
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
// Fall through to failed.
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
gitInWorktree(worktreeDir, ["merge", "--abort"], { stdio: "ignore" });
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// Best-effort.
|
|
410
|
+
}
|
|
411
|
+
removeWorktree(repoDir, worktreeDir);
|
|
412
|
+
return "failed";
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/** Blocker-first merge attempt, mirroring `cmd && … || true` hook semantics. */
|
|
416
|
+
function attemptBlockerFirstMerges(worktreeDir, mergedBlockerPrNumbers) {
|
|
417
|
+
try {
|
|
418
|
+
for (const n of mergedBlockerPrNumbers) {
|
|
419
|
+
gitInWorktree(worktreeDir, ["fetch", "origin", `pull/${n}/head`], { stdio: "inherit" });
|
|
420
|
+
gitInWorktree(worktreeDir, ["merge", "FETCH_HEAD"], { stdio: "pipe" });
|
|
421
|
+
}
|
|
422
|
+
gitInWorktree(worktreeDir, ["fetch", "origin", config.defaultBranch], { stdio: "inherit" });
|
|
423
|
+
gitInWorktree(worktreeDir, ["merge", `origin/${config.defaultBranch}`], { stdio: "pipe" });
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// Conflicts stay in the tree for the agent to resolve.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async function runConflictResolutionAgent(pr, mergedBlockerPrNumbers) {
|
|
430
|
+
const branch = pr.headRefName;
|
|
431
|
+
fetchOrigin();
|
|
432
|
+
const originShaBefore = originBranchSha(branch);
|
|
433
|
+
const worktreeDir = prepareWorktree({ branch });
|
|
434
|
+
try {
|
|
435
|
+
runShellCommand(config.installCommand, worktreeDir);
|
|
436
|
+
attemptBlockerFirstMerges(worktreeDir, mergedBlockerPrNumbers);
|
|
437
|
+
await runAgentInWorktree({
|
|
438
|
+
worktreeDir,
|
|
439
|
+
promptFile: config.promptFiles.conflict,
|
|
440
|
+
promptArgs: {
|
|
441
|
+
PR_NUMBER: String(pr.prNumber),
|
|
442
|
+
PR_BRANCH: branch,
|
|
443
|
+
BLOCKER_PR_NUMBERS: mergedBlockerPrNumbers.join(","),
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
fetchOrigin();
|
|
447
|
+
const originShaAfter = originBranchSha(branch);
|
|
448
|
+
const prInfo = viewPrMergeInfo(pr.prNumber);
|
|
449
|
+
const localCommitCount = commitCount(worktreeDir, `origin/${branch}..HEAD`);
|
|
450
|
+
let pushed = false;
|
|
451
|
+
if (shouldPostConflictFixFailure({
|
|
452
|
+
hostCommitCount: localCommitCount,
|
|
453
|
+
originShaBefore,
|
|
454
|
+
originShaAfter,
|
|
455
|
+
mergeable: prInfo.mergeable,
|
|
456
|
+
mergeStateStatus: prInfo.mergeStateStatus,
|
|
457
|
+
})) {
|
|
458
|
+
console.log(`[phoebe] Conflict fix for PR #${pr.prNumber} produced no commits — leaving PR unchanged.`);
|
|
459
|
+
postPrComment(pr.prNumber, conflictFixFailureComment(pr.prNumber, currentConflictFailureWatermark(pr.headRefName)));
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
if (localCommitCount > 0) {
|
|
463
|
+
pushBranch(worktreeDir, branch);
|
|
464
|
+
pushed = true;
|
|
465
|
+
console.log(`[phoebe] Conflict resolved for PR #${pr.prNumber} — pushed.`);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
pushed = true;
|
|
469
|
+
console.log(`[phoebe] Conflict resolved for PR #${pr.prNumber} — already pushed by agent.`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return pushed;
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
removeWorktree(repoDir, worktreeDir);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
async function fixOnePrConflict(pr, issueBodies, blockerStates) {
|
|
479
|
+
console.log(`[phoebe] Conflict fix: PR #${pr.prNumber} (${pr.headRefName}).`);
|
|
480
|
+
fetchOrigin();
|
|
481
|
+
const issueNumber = pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName);
|
|
482
|
+
const body = issueNumber !== null ? (issueBodies.get(issueNumber) ?? "") : "";
|
|
483
|
+
const mergedBlockerPrNumbers = getMergedBlockerPrNumbers(body, blockerStates);
|
|
484
|
+
if (mergedBlockerPrNumbers.length > 0) {
|
|
485
|
+
console.log(`[phoebe] Stacked catch-up: merging blocker PR(s) ${mergedBlockerPrNumbers.map((n) => `#${n}`).join(", ")} before ${config.defaultBranch}.`);
|
|
486
|
+
}
|
|
487
|
+
const cleanResult = tryCleanMerge(pr.headRefName, mergedBlockerPrNumbers);
|
|
488
|
+
if (cleanResult === "pushed") {
|
|
489
|
+
console.log(`[phoebe] Clean merge for PR #${pr.prNumber} — pushed.`);
|
|
490
|
+
if (mergedBlockerPrNumbers.length > 0) {
|
|
491
|
+
postPrComment(pr.prNumber, stackedCatchUpRetractionComment(mergedBlockerPrNumbers));
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (cleanResult === "failed") {
|
|
496
|
+
console.log(`[phoebe] Could not start merge for PR #${pr.prNumber} — skipping.`);
|
|
497
|
+
postPrComment(pr.prNumber, conflictFixFailureComment(pr.prNumber, currentConflictFailureWatermark(pr.headRefName)));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
await runConflictResolutionAgent(pr, mergedBlockerPrNumbers);
|
|
501
|
+
}
|
|
502
|
+
async function runChecksResolutionAgent(pr) {
|
|
503
|
+
const branch = pr.headRefName;
|
|
504
|
+
fetchOrigin();
|
|
505
|
+
const originShaBefore = originBranchSha(branch);
|
|
506
|
+
const worktreeDir = prepareWorktree({ branch });
|
|
507
|
+
try {
|
|
508
|
+
runShellCommand(config.installCommand, worktreeDir);
|
|
509
|
+
await runAgentInWorktree({
|
|
510
|
+
worktreeDir,
|
|
511
|
+
promptFile: config.promptFiles.checks,
|
|
512
|
+
promptArgs: {
|
|
513
|
+
PR_NUMBER: String(pr.prNumber),
|
|
514
|
+
PR_BRANCH: branch,
|
|
515
|
+
FAILING_CHECKS: formatFailingChecksForPrompt(pr.failingChecks),
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
fetchOrigin();
|
|
519
|
+
const originShaAfter = originBranchSha(branch);
|
|
520
|
+
const localCommitCount = commitCount(worktreeDir, `origin/${branch}..HEAD`);
|
|
521
|
+
let pushed = false;
|
|
522
|
+
if (shouldPostChecksFixFailure({
|
|
523
|
+
hostCommitCount: localCommitCount,
|
|
524
|
+
originShaBefore,
|
|
525
|
+
originShaAfter,
|
|
526
|
+
})) {
|
|
527
|
+
console.log(`[phoebe] Checks fix for PR #${pr.prNumber} produced no commits — leaving PR unchanged.`);
|
|
528
|
+
postPrComment(pr.prNumber, checksFixFailureComment(pr.prNumber, currentChecksFailureWatermark(pr.headRefName)));
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
if (localCommitCount > 0) {
|
|
532
|
+
pushBranch(worktreeDir, branch);
|
|
533
|
+
pushed = true;
|
|
534
|
+
console.log(`[phoebe] Checks fixed for PR #${pr.prNumber} — pushed.`);
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
pushed = true;
|
|
538
|
+
console.log(`[phoebe] Checks fixed for PR #${pr.prNumber} — already pushed by agent.`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return pushed;
|
|
542
|
+
}
|
|
543
|
+
finally {
|
|
544
|
+
removeWorktree(repoDir, worktreeDir);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async function fixOnePrChecks(pr, issueBodies, blockerStates) {
|
|
548
|
+
console.log(`[phoebe] Checks fix: PR #${pr.prNumber} (${pr.headRefName}) — ` +
|
|
549
|
+
`${pr.failingChecks.map((c) => c.name).join(", ")}.`);
|
|
550
|
+
fetchOrigin();
|
|
551
|
+
if (pr.mergeStateStatus === "BEHIND") {
|
|
552
|
+
const issueNumber = pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName);
|
|
553
|
+
const body = issueNumber !== null ? (issueBodies.get(issueNumber) ?? "") : "";
|
|
554
|
+
const mergedBlockerPrNumbers = getMergedBlockerPrNumbers(body, blockerStates);
|
|
555
|
+
if (mergedBlockerPrNumbers.length > 0) {
|
|
556
|
+
console.log(`[phoebe] Behind main — catch-up merging blocker PR(s) ${mergedBlockerPrNumbers.map((n) => `#${n}`).join(", ")} before ${config.defaultBranch}.`);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
console.log(`[phoebe] Behind main — catch-up merge for PR #${pr.prNumber}.`);
|
|
560
|
+
}
|
|
561
|
+
const cleanResult = tryCleanMerge(pr.headRefName, mergedBlockerPrNumbers);
|
|
562
|
+
if (cleanResult === "pushed") {
|
|
563
|
+
console.log(`[phoebe] Catch-up merge for PR #${pr.prNumber} — pushed; waiting for CI on next cycle.`);
|
|
564
|
+
if (mergedBlockerPrNumbers.length > 0) {
|
|
565
|
+
postPrComment(pr.prNumber, stackedCatchUpRetractionComment(mergedBlockerPrNumbers));
|
|
566
|
+
}
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (cleanResult === "needs_agent" || cleanResult === "failed") {
|
|
570
|
+
console.log(`[phoebe] Catch-up merge conflicted for PR #${pr.prNumber} — deferring to conflicts mode.`);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
await runChecksResolutionAgent(pr);
|
|
575
|
+
}
|
|
576
|
+
function fetchReviewThreads(prNumber) {
|
|
577
|
+
const [owner, repo] = config.repoSlug.split("/");
|
|
578
|
+
const threads = [];
|
|
579
|
+
let cursor = null;
|
|
580
|
+
let hasNextPage = true;
|
|
581
|
+
while (hasNextPage) {
|
|
582
|
+
const afterArg = cursor ? `, after:"${cursor}"` : "";
|
|
583
|
+
const query = `query($owner:String!,$repo:String!,$pr:Int!) {
|
|
584
|
+
repository(owner:$owner,name:$repo) {
|
|
585
|
+
pullRequest(number:$pr) {
|
|
586
|
+
reviewThreads(first:100${afterArg}) {
|
|
587
|
+
pageInfo { hasNextPage endCursor }
|
|
588
|
+
nodes {
|
|
589
|
+
isResolved
|
|
590
|
+
isOutdated
|
|
591
|
+
comments(first:30) {
|
|
592
|
+
nodes {
|
|
593
|
+
createdAt
|
|
594
|
+
author { login }
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}`;
|
|
602
|
+
const page = JSON.parse(execFileSync("gh", [
|
|
603
|
+
"api",
|
|
604
|
+
"graphql",
|
|
605
|
+
"-f",
|
|
606
|
+
`query=${query}`,
|
|
607
|
+
"-f",
|
|
608
|
+
`owner=${owner}`,
|
|
609
|
+
"-f",
|
|
610
|
+
`repo=${repo}`,
|
|
611
|
+
"-F",
|
|
612
|
+
`pr=${prNumber}`,
|
|
613
|
+
], { encoding: "utf8", timeout: CHILD_PROCESS_TIMEOUT_MS }));
|
|
614
|
+
const reviewThreads = page.data.repository.pullRequest.reviewThreads;
|
|
615
|
+
for (const node of reviewThreads.nodes) {
|
|
616
|
+
threads.push({
|
|
617
|
+
isResolved: node.isResolved,
|
|
618
|
+
isOutdated: node.isOutdated,
|
|
619
|
+
comments: node.comments.nodes.map((comment) => ({
|
|
620
|
+
createdAt: comment.createdAt,
|
|
621
|
+
authorLogin: comment.author?.login ?? "",
|
|
622
|
+
})),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
hasNextPage = reviewThreads.pageInfo.hasNextPage;
|
|
626
|
+
cursor = reviewThreads.pageInfo.endCursor;
|
|
627
|
+
if (!hasNextPage) {
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return threads;
|
|
632
|
+
}
|
|
633
|
+
function hasNewReviewSummaryComment(prNumber, phoebeLogin, since) {
|
|
634
|
+
const { comments } = ghJson(["pr", "view", String(prNumber), "--json", "comments"]);
|
|
635
|
+
return comments.some((comment) => comment.author.login === phoebeLogin &&
|
|
636
|
+
comment.createdAt > since &&
|
|
637
|
+
isReviewSummaryComment(comment.body));
|
|
638
|
+
}
|
|
639
|
+
async function runReviewsResolutionAgent(pr, phoebeLogin) {
|
|
640
|
+
const branch = pr.headRefName;
|
|
641
|
+
const runStartedAt = new Date().toISOString();
|
|
642
|
+
fetchOrigin();
|
|
643
|
+
const originShaBefore = originBranchSha(branch);
|
|
644
|
+
const worktreeDir = prepareWorktree({ branch });
|
|
645
|
+
try {
|
|
646
|
+
runShellCommand(config.installCommand, worktreeDir);
|
|
647
|
+
await runAgentInWorktree({
|
|
648
|
+
worktreeDir,
|
|
649
|
+
promptFile: config.promptFiles.reviews,
|
|
650
|
+
promptArgs: {
|
|
651
|
+
PR_NUMBER: String(pr.prNumber),
|
|
652
|
+
PR_BRANCH: branch,
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
fetchOrigin();
|
|
656
|
+
const originShaAfter = originBranchSha(branch);
|
|
657
|
+
const localCommitCount = commitCount(worktreeDir, `origin/${branch}..HEAD`);
|
|
658
|
+
if (localCommitCount > 0) {
|
|
659
|
+
pushBranch(worktreeDir, branch);
|
|
660
|
+
console.log(`[phoebe] Review feedback handled for PR #${pr.prNumber} — pushed.`);
|
|
661
|
+
}
|
|
662
|
+
else if (originShaAfter !== originShaBefore) {
|
|
663
|
+
console.log(`[phoebe] Review feedback handled for PR #${pr.prNumber} — already pushed by agent.`);
|
|
664
|
+
}
|
|
665
|
+
const hasSummary = hasNewReviewSummaryComment(pr.prNumber, phoebeLogin, runStartedAt);
|
|
666
|
+
const pushed = localCommitCount > 0 || originShaAfter !== originShaBefore;
|
|
667
|
+
// Watermark only the activity captured before the agent ran (pr.threads is
|
|
668
|
+
// the pre-run snapshot from fetchReviewsWorkData). Re-fetching here could
|
|
669
|
+
// absorb feedback posted concurrently with the run — marking it handled
|
|
670
|
+
// even though the agent never observed it, so it would never trigger another
|
|
671
|
+
// cycle. Any activity newer than this snapshot correctly re-selects the PR.
|
|
672
|
+
const latestActivityAt = newestReviewThreadCommentCreatedAt(pr.threads);
|
|
673
|
+
if (hasSummary) {
|
|
674
|
+
console.log(`[phoebe] Review summary posted for PR #${pr.prNumber}.`);
|
|
675
|
+
}
|
|
676
|
+
else if (!pushed) {
|
|
677
|
+
console.log(`[phoebe] Review handling for PR #${pr.prNumber} produced no summary or push.`);
|
|
678
|
+
}
|
|
679
|
+
postPrComment(pr.prNumber, buildReviewsHandledComment({
|
|
680
|
+
latestActivityAt,
|
|
681
|
+
failed: !hasSummary && !pushed,
|
|
682
|
+
}));
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
removeWorktree(repoDir, worktreeDir);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
async function fixOnePrReviews(pr, phoebeLogin) {
|
|
689
|
+
console.log(`[phoebe] Reviews fix: PR #${pr.prNumber} (${pr.headRefName}).`);
|
|
690
|
+
fetchOrigin();
|
|
691
|
+
await runReviewsResolutionAgent(pr, phoebeLogin);
|
|
692
|
+
}
|
|
693
|
+
async function runOneIssue(issueNumber, issueTitle, worktreeBase, stacked, blockerIssueNumber, blockerPrNumber) {
|
|
694
|
+
const agentBranch = issueBranch(issueNumber);
|
|
695
|
+
fetchOrigin();
|
|
696
|
+
const worktreeDir = prepareWorktree({ branch: agentBranch, baseRef: worktreeBase });
|
|
697
|
+
try {
|
|
698
|
+
runShellCommand(config.installCommand, worktreeDir);
|
|
699
|
+
await runAgentInWorktree({
|
|
700
|
+
worktreeDir,
|
|
701
|
+
promptFile: config.promptFiles.issue,
|
|
702
|
+
promptArgs: { ISSUE_NUMBER: String(issueNumber) },
|
|
703
|
+
});
|
|
704
|
+
const newCommitCount = commitCount(worktreeDir, `${worktreeBase}..HEAD`);
|
|
705
|
+
if (newCommitCount > 0) {
|
|
706
|
+
pushBranch(worktreeDir, agentBranch);
|
|
707
|
+
const existingPr = ghJson([
|
|
708
|
+
"pr",
|
|
709
|
+
"list",
|
|
710
|
+
"--head",
|
|
711
|
+
agentBranch,
|
|
712
|
+
"--state",
|
|
713
|
+
"open",
|
|
714
|
+
"--json",
|
|
715
|
+
"number",
|
|
716
|
+
])[0]?.number;
|
|
717
|
+
if (existingPr === undefined) {
|
|
718
|
+
const prTitle = `Phoebe: ${issueTitle} (#${issueNumber})`;
|
|
719
|
+
const prBody = buildInitialPrBody({
|
|
720
|
+
issueNumber,
|
|
721
|
+
commitCount: newCommitCount,
|
|
722
|
+
...(stacked && blockerIssueNumber !== undefined && blockerPrNumber !== undefined
|
|
723
|
+
? { stacked: { blockerIssueNumber, blockerPrNumber } }
|
|
724
|
+
: {}),
|
|
725
|
+
});
|
|
726
|
+
gh([
|
|
727
|
+
"pr",
|
|
728
|
+
"create",
|
|
729
|
+
"--head",
|
|
730
|
+
agentBranch,
|
|
731
|
+
"--base",
|
|
732
|
+
PR_BASE,
|
|
733
|
+
"--title",
|
|
734
|
+
prTitle,
|
|
735
|
+
"--body-file",
|
|
736
|
+
"-",
|
|
737
|
+
], { input: prBody });
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
console.log(`[phoebe] PR #${existingPr} already exists for ${agentBranch} — posting follow-up note.`);
|
|
741
|
+
postPrComment(existingPr, followUpPrComment(issueNumber, newCommitCount));
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
console.log("[phoebe] No commits — skipping PR creation.");
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
finally {
|
|
749
|
+
removeWorktree(repoDir, worktreeDir);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
async function conflictingPrCandidate(pr) {
|
|
753
|
+
for (let attempt = 0; attempt < MERGEABLE_RETRY_COUNT; attempt++) {
|
|
754
|
+
const info = viewPrMergeInfo(pr.number);
|
|
755
|
+
if (isPrMergeConflicting(info.mergeable, info.mergeStateStatus)) {
|
|
756
|
+
const issueNumber = parseIssueNumberFromBranch(info.headRefName);
|
|
757
|
+
return {
|
|
758
|
+
prNumber: info.number,
|
|
759
|
+
headRefName: info.headRefName,
|
|
760
|
+
headSha: info.headRefOid,
|
|
761
|
+
...(issueNumber !== null ? { issueNumber } : {}),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
if (info.mergeable !== "UNKNOWN") {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
if (attempt < MERGEABLE_RETRY_COUNT - 1) {
|
|
768
|
+
await sleep(MERGEABLE_RETRY_MS);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
async function fetchConflictingPrs() {
|
|
774
|
+
const openPrs = listOpenPhoebePrs();
|
|
775
|
+
const conflicting = [];
|
|
776
|
+
for (const pr of openPrs) {
|
|
777
|
+
try {
|
|
778
|
+
const candidate = await conflictingPrCandidate(pr);
|
|
779
|
+
if (candidate) {
|
|
780
|
+
conflicting.push(candidate);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
console.warn(`[phoebe] Skipping PR #${pr.number} for conflicts this cycle — ${error instanceof Error ? error.message : String(error)}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return conflicting;
|
|
788
|
+
}
|
|
789
|
+
// GraphQL statusCheckRollup is not readable by fine-grained PATs (GitHub-App/
|
|
790
|
+
// OAuth only), so check state comes from the REST Actions API instead.
|
|
791
|
+
function listCommitCheckItems(headSha) {
|
|
792
|
+
return workflowRunsToCheckItems(ghJson([
|
|
793
|
+
"run",
|
|
794
|
+
"list",
|
|
795
|
+
"--commit",
|
|
796
|
+
headSha,
|
|
797
|
+
"--json",
|
|
798
|
+
"workflowName,status,conclusion",
|
|
799
|
+
"--limit",
|
|
800
|
+
"50",
|
|
801
|
+
]));
|
|
802
|
+
}
|
|
803
|
+
async function failingChecksCandidate(pr) {
|
|
804
|
+
for (let attempt = 0; attempt < MERGEABLE_RETRY_COUNT; attempt++) {
|
|
805
|
+
const info = viewPrMergeInfo(pr.number);
|
|
806
|
+
if (isPrMergeConflicting(info.mergeable, info.mergeStateStatus)) {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
const checkItems = listCommitCheckItems(info.headRefOid);
|
|
810
|
+
const rollup = statusCheckRollupState(checkItems);
|
|
811
|
+
if (rollup === "FAILURE") {
|
|
812
|
+
const issueNumber = parseIssueNumberFromBranch(info.headRefName);
|
|
813
|
+
return {
|
|
814
|
+
prNumber: info.number,
|
|
815
|
+
headRefName: info.headRefName,
|
|
816
|
+
headSha: info.headRefOid,
|
|
817
|
+
mergeable: info.mergeable,
|
|
818
|
+
mergeStateStatus: info.mergeStateStatus,
|
|
819
|
+
failingChecks: listFailingChecks(checkItems),
|
|
820
|
+
...(issueNumber !== null ? { issueNumber } : {}),
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
if (rollup !== "PENDING" && info.mergeable !== "UNKNOWN") {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
if (attempt < MERGEABLE_RETRY_COUNT - 1) {
|
|
827
|
+
await sleep(MERGEABLE_RETRY_MS);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
async function fetchFailingCheckPrs() {
|
|
833
|
+
const openPrs = listOpenPhoebePrs();
|
|
834
|
+
const failing = [];
|
|
835
|
+
for (const pr of openPrs) {
|
|
836
|
+
try {
|
|
837
|
+
const candidate = await failingChecksCandidate(pr);
|
|
838
|
+
if (candidate) {
|
|
839
|
+
failing.push(candidate);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
catch (error) {
|
|
843
|
+
console.warn(`[phoebe] Skipping PR #${pr.number} for checks this cycle — ${error instanceof Error ? error.message : String(error)}`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return failing;
|
|
847
|
+
}
|
|
848
|
+
async function fetchReviewsWorkData() {
|
|
849
|
+
const phoebeLogin = phoebeGhLogin();
|
|
850
|
+
const openPrs = listOpenPhoebePrs();
|
|
851
|
+
const reviewActivityPrs = [];
|
|
852
|
+
for (const pr of openPrs) {
|
|
853
|
+
try {
|
|
854
|
+
const info = viewPrMergeInfo(pr.number);
|
|
855
|
+
if (isPrMergeConflicting(info.mergeable, info.mergeStateStatus)) {
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
const threads = fetchReviewThreads(pr.number);
|
|
859
|
+
const issueNumber = parseIssueNumberFromBranch(info.headRefName);
|
|
860
|
+
reviewActivityPrs.push({
|
|
861
|
+
prNumber: info.number,
|
|
862
|
+
headRefName: info.headRefName,
|
|
863
|
+
authorLogin: pr.authorLogin,
|
|
864
|
+
mergeable: info.mergeable,
|
|
865
|
+
mergeStateStatus: info.mergeStateStatus,
|
|
866
|
+
threads,
|
|
867
|
+
handledWatermark: prReviewsHandledWatermark(pr.number),
|
|
868
|
+
...(issueNumber !== null ? { issueNumber } : {}),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
catch (error) {
|
|
872
|
+
console.warn(`[phoebe] Skipping PR #${pr.number} for reviews this cycle — ${error instanceof Error ? error.message : String(error)}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const issueNumbers = [
|
|
876
|
+
...new Set(reviewActivityPrs
|
|
877
|
+
.map((pr) => pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName))
|
|
878
|
+
.filter((n) => n !== null)),
|
|
879
|
+
];
|
|
880
|
+
const issueBodies = new Map(issueNumbers.map((number) => [number, issueBody(number)]));
|
|
881
|
+
return { reviewActivityPrs, issueBodies, phoebeLogin };
|
|
882
|
+
}
|
|
883
|
+
async function fetchConflictWorkData() {
|
|
884
|
+
const rawConflictingPrs = await fetchConflictingPrs();
|
|
885
|
+
fetchOrigin();
|
|
886
|
+
const currentMainHead = originBranchSha(config.defaultBranch);
|
|
887
|
+
const conflictingPrs = rawConflictingPrs.map((pr) => ({
|
|
888
|
+
...pr,
|
|
889
|
+
failureWatermark: prConflictFailWatermark(pr.prNumber),
|
|
890
|
+
}));
|
|
891
|
+
const issueNumbers = [
|
|
892
|
+
...new Set(conflictingPrs
|
|
893
|
+
.map((pr) => pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName))
|
|
894
|
+
.filter((n) => n !== null)),
|
|
895
|
+
];
|
|
896
|
+
const issueBodies = new Map(issueNumbers.map((number) => [number, issueBody(number)]));
|
|
897
|
+
return { conflictingPrs, issueBodies, currentMainHead };
|
|
898
|
+
}
|
|
899
|
+
async function fetchChecksWorkData() {
|
|
900
|
+
const rawFailingPrs = await fetchFailingCheckPrs();
|
|
901
|
+
const failingCheckPrs = rawFailingPrs.map((pr) => ({
|
|
902
|
+
...pr,
|
|
903
|
+
failureWatermark: prChecksFailWatermark(pr.prNumber),
|
|
904
|
+
}));
|
|
905
|
+
const issueNumbers = [
|
|
906
|
+
...new Set(failingCheckPrs
|
|
907
|
+
.map((pr) => pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName))
|
|
908
|
+
.filter((n) => n !== null)),
|
|
909
|
+
];
|
|
910
|
+
const issueBodies = new Map(issueNumbers.map((number) => [number, issueBody(number)]));
|
|
911
|
+
return { failingCheckPrs, issueBodies };
|
|
912
|
+
}
|
|
913
|
+
function fetchIssueWorkData() {
|
|
914
|
+
const issues = listReadyIssues();
|
|
915
|
+
return { issues, blockerStates: buildBlockerStates(issues) };
|
|
916
|
+
}
|
|
917
|
+
async function runIssueUnit(unit) {
|
|
918
|
+
const { issue: target, resolution } = unit;
|
|
919
|
+
console.log(`[phoebe] Working #${target.number} — base ${resolution.worktreeBase}` +
|
|
920
|
+
(resolution.stacked ? ` (stacked on #${resolution.blockerIssueNumber})` : "") +
|
|
921
|
+
".");
|
|
922
|
+
await runOneIssue(target.number, target.title, resolution.worktreeBase, resolution.stacked, resolution.blockerIssueNumber, resolution.blockerPrNumber);
|
|
923
|
+
}
|
|
924
|
+
let conflictRunContext = {
|
|
925
|
+
issueBodies: new Map(),
|
|
926
|
+
blockerStates: new Map(),
|
|
927
|
+
};
|
|
928
|
+
let checksRunContext = {
|
|
929
|
+
issueBodies: new Map(),
|
|
930
|
+
blockerStates: new Map(),
|
|
931
|
+
};
|
|
932
|
+
let reviewsRunContext = { phoebeLogin: "" };
|
|
933
|
+
const KINDS = {
|
|
934
|
+
conflicts: {
|
|
935
|
+
name: "conflicts",
|
|
936
|
+
fetch: async () => {
|
|
937
|
+
const { conflictingPrs, issueBodies, currentMainHead } = await fetchConflictWorkData();
|
|
938
|
+
return { kind: "conflicts", conflictingPrs, issueBodies, currentMainHead };
|
|
939
|
+
},
|
|
940
|
+
runUnit: async (unit) => {
|
|
941
|
+
await fixOnePrConflict(unit, conflictRunContext.issueBodies, conflictRunContext.blockerStates);
|
|
942
|
+
},
|
|
943
|
+
},
|
|
944
|
+
checks: {
|
|
945
|
+
name: "checks",
|
|
946
|
+
fetch: async () => {
|
|
947
|
+
const { failingCheckPrs, issueBodies } = await fetchChecksWorkData();
|
|
948
|
+
return { kind: "checks", failingCheckPrs, issueBodies };
|
|
949
|
+
},
|
|
950
|
+
runUnit: async (unit) => {
|
|
951
|
+
await fixOnePrChecks(unit, checksRunContext.issueBodies, checksRunContext.blockerStates);
|
|
952
|
+
},
|
|
953
|
+
},
|
|
954
|
+
reviews: {
|
|
955
|
+
name: "reviews",
|
|
956
|
+
fetch: async () => {
|
|
957
|
+
const { reviewActivityPrs, issueBodies, phoebeLogin } = await fetchReviewsWorkData();
|
|
958
|
+
return { kind: "reviews", reviewActivityPrs, issueBodies, phoebeLogin };
|
|
959
|
+
},
|
|
960
|
+
runUnit: async (unit) => {
|
|
961
|
+
await fixOnePrReviews(unit, reviewsRunContext.phoebeLogin);
|
|
962
|
+
},
|
|
963
|
+
},
|
|
964
|
+
issues: {
|
|
965
|
+
name: "issues",
|
|
966
|
+
fetch: async () => {
|
|
967
|
+
const { issues, blockerStates } = fetchIssueWorkData();
|
|
968
|
+
return { kind: "issues", issues, blockerStates };
|
|
969
|
+
},
|
|
970
|
+
runUnit: async (unit) => {
|
|
971
|
+
await runIssueUnit(unit);
|
|
972
|
+
},
|
|
973
|
+
},
|
|
974
|
+
};
|
|
975
|
+
async function fetchCycleWorkData(kinds) {
|
|
976
|
+
let issues = [];
|
|
977
|
+
let blockerStates = new Map();
|
|
978
|
+
let conflictingPrs = [];
|
|
979
|
+
let failingCheckPrs = [];
|
|
980
|
+
let reviewActivityPrs = [];
|
|
981
|
+
let issueBodies = new Map();
|
|
982
|
+
let phoebeLogin;
|
|
983
|
+
let currentMainHead;
|
|
984
|
+
for (const kind of kinds) {
|
|
985
|
+
const fetched = await KINDS[kind].fetch();
|
|
986
|
+
if (fetched.kind === "issues") {
|
|
987
|
+
issues = fetched.issues;
|
|
988
|
+
blockerStates = fetched.blockerStates;
|
|
989
|
+
}
|
|
990
|
+
else if (fetched.kind === "conflicts") {
|
|
991
|
+
conflictingPrs = fetched.conflictingPrs;
|
|
992
|
+
issueBodies = fetched.issueBodies;
|
|
993
|
+
currentMainHead = fetched.currentMainHead;
|
|
994
|
+
}
|
|
995
|
+
else if (fetched.kind === "checks") {
|
|
996
|
+
failingCheckPrs = fetched.failingCheckPrs;
|
|
997
|
+
for (const [number, body] of fetched.issueBodies) {
|
|
998
|
+
issueBodies.set(number, body);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
reviewActivityPrs = fetched.reviewActivityPrs;
|
|
1003
|
+
phoebeLogin = fetched.phoebeLogin;
|
|
1004
|
+
for (const [number, body] of fetched.issueBodies) {
|
|
1005
|
+
issueBodies.set(number, body);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const allBodies = [...issueBodies.entries()].map(([number, body]) => ({ number, body }));
|
|
1010
|
+
if (allBodies.length > 0) {
|
|
1011
|
+
const mergedBlockerStates = buildBlockerStatesFromBodies(allBodies);
|
|
1012
|
+
for (const [blockerIssue, state] of mergedBlockerStates) {
|
|
1013
|
+
blockerStates.set(blockerIssue, state);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
conflictRunContext = { issueBodies, blockerStates };
|
|
1017
|
+
checksRunContext = { issueBodies, blockerStates };
|
|
1018
|
+
if (phoebeLogin) {
|
|
1019
|
+
reviewsRunContext = { phoebeLogin };
|
|
1020
|
+
}
|
|
1021
|
+
return {
|
|
1022
|
+
issues,
|
|
1023
|
+
blockerStates,
|
|
1024
|
+
conflictingPrs,
|
|
1025
|
+
failingCheckPrs,
|
|
1026
|
+
reviewActivityPrs,
|
|
1027
|
+
issueBodies,
|
|
1028
|
+
phoebeLogin,
|
|
1029
|
+
currentMainHead,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
function logIdleCycle(data) {
|
|
1033
|
+
const phoebeBase = process.env["PHOEBE_BASE"];
|
|
1034
|
+
if (data.issues.length > 0 && !selectIssue(data.issues, data.blockerStates, phoebeBase)) {
|
|
1035
|
+
console.log(`[phoebe] ${data.issues.length} ${config.readyLabel} issue(s) but none workable this cycle (blocked or waiting on blocker PR).`);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (data.conflictingPrs.length > 0) {
|
|
1039
|
+
const conflictBlockerStates = buildBlockerStatesFromBodies([...data.issueBodies.entries()].map(([number, body]) => ({ number, body })));
|
|
1040
|
+
const conflictOpts = data.currentMainHead
|
|
1041
|
+
? { currentMainHead: data.currentMainHead }
|
|
1042
|
+
: undefined;
|
|
1043
|
+
const candidatesWithoutWatermark = selectConflictFixCandidates(data.conflictingPrs, data.issueBodies, conflictBlockerStates);
|
|
1044
|
+
const candidates = selectConflictFixCandidates(data.conflictingPrs, data.issueBodies, conflictBlockerStates, conflictOpts);
|
|
1045
|
+
const unit = selectConflictUnit(data.conflictingPrs, data.issueBodies, conflictBlockerStates, conflictOpts);
|
|
1046
|
+
const skippedStacked = data.conflictingPrs.length - candidatesWithoutWatermark.length;
|
|
1047
|
+
const skippedWatermark = candidatesWithoutWatermark.length - candidates.length;
|
|
1048
|
+
if (skippedStacked > 0) {
|
|
1049
|
+
console.log(`[phoebe] ${skippedStacked} conflicting PR(s) skipped (stacked on open blocker).`);
|
|
1050
|
+
}
|
|
1051
|
+
if (skippedWatermark > 0) {
|
|
1052
|
+
console.log(`[phoebe] ${skippedWatermark} conflicting PR(s) skipped (unchanged failure watermark).`);
|
|
1053
|
+
}
|
|
1054
|
+
if (!unit) {
|
|
1055
|
+
console.log(`[phoebe] ${data.conflictingPrs.length} conflicting PR(s) but none fixable this cycle.`);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (data.failingCheckPrs.length > 0) {
|
|
1060
|
+
const checksBlockerStates = buildBlockerStatesFromBodies([...data.issueBodies.entries()].map(([number, body]) => ({ number, body })));
|
|
1061
|
+
const candidatesWithoutWatermark = selectChecksCandidates(data.failingCheckPrs, data.issueBodies, checksBlockerStates);
|
|
1062
|
+
const unit = selectChecksUnit(data.failingCheckPrs, data.issueBodies, checksBlockerStates);
|
|
1063
|
+
const skippedStacked = data.failingCheckPrs.length - candidatesWithoutWatermark.length;
|
|
1064
|
+
if (skippedStacked > 0) {
|
|
1065
|
+
console.log(`[phoebe] ${skippedStacked} failing-CI PR(s) skipped (stacked or watermarked).`);
|
|
1066
|
+
}
|
|
1067
|
+
if (!unit) {
|
|
1068
|
+
console.log(`[phoebe] ${data.failingCheckPrs.length} failing-CI PR(s) but none fixable this cycle.`);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
if (data.reviewActivityPrs.length > 0 && data.phoebeLogin) {
|
|
1073
|
+
const reviewsBlockerStates = buildBlockerStatesFromBodies([...data.issueBodies.entries()].map(([number, body]) => ({ number, body })));
|
|
1074
|
+
const candidates = selectReviewsCandidates(data.reviewActivityPrs, data.issueBodies, reviewsBlockerStates, data.phoebeLogin);
|
|
1075
|
+
const unit = selectReviewsUnit(data.reviewActivityPrs, data.issueBodies, reviewsBlockerStates, data.phoebeLogin);
|
|
1076
|
+
const skipped = data.reviewActivityPrs.length - candidates.length;
|
|
1077
|
+
if (skipped > 0) {
|
|
1078
|
+
console.log(`[phoebe] ${skipped} review-feedback PR(s) skipped (stacked, watermarked, or no new activity).`);
|
|
1079
|
+
}
|
|
1080
|
+
if (!unit) {
|
|
1081
|
+
console.log(`[phoebe] ${data.reviewActivityPrs.length} review-feedback PR(s) but none fixable this cycle.`);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
console.log("[phoebe] No work this cycle — idle.");
|
|
1086
|
+
}
|
|
1087
|
+
function sleep(ms) {
|
|
1088
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1089
|
+
}
|
|
1090
|
+
function describeUnit(picked) {
|
|
1091
|
+
if (picked.kind === "conflicts") {
|
|
1092
|
+
const unit = picked.unit;
|
|
1093
|
+
return `conflict fix for PR #${unit.prNumber} (${unit.headRefName})`;
|
|
1094
|
+
}
|
|
1095
|
+
if (picked.kind === "checks") {
|
|
1096
|
+
const unit = picked.unit;
|
|
1097
|
+
return `checks fix for PR #${unit.prNumber} (${unit.headRefName})`;
|
|
1098
|
+
}
|
|
1099
|
+
if (picked.kind === "reviews") {
|
|
1100
|
+
const unit = picked.unit;
|
|
1101
|
+
return `review feedback for PR #${unit.prNumber} (${unit.headRefName})`;
|
|
1102
|
+
}
|
|
1103
|
+
const unit = picked.unit;
|
|
1104
|
+
return `issue #${unit.issue.number} — base ${unit.resolution.worktreeBase}`;
|
|
1105
|
+
}
|
|
1106
|
+
// ---------------------------------------------------------------------------
|
|
1107
|
+
// Main loop
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
/**
|
|
1110
|
+
* Drive the Phoebe worker loop until it exits (persistent mode) or completes
|
|
1111
|
+
* one unit (`--run-once`). Called by src/cli.ts after the resolved config is
|
|
1112
|
+
* installed; the CLI passes its argv with `--config <path>` already stripped
|
|
1113
|
+
* so this only sees engine-level flags.
|
|
1114
|
+
*/
|
|
1115
|
+
export async function runEngine(argv = process.argv.slice(2)) {
|
|
1116
|
+
const runOnce = argv.includes("--run-once");
|
|
1117
|
+
const dryRun = argv.includes("--dry-run");
|
|
1118
|
+
const rawPollIntervalMs = Number(process.env["PHOEBE_POLL_INTERVAL_MS"]);
|
|
1119
|
+
const pollIntervalMs = Number.isFinite(rawPollIntervalMs) && rawPollIntervalMs > 0
|
|
1120
|
+
? rawPollIntervalMs
|
|
1121
|
+
: DEFAULT_POLL_INTERVAL_MS;
|
|
1122
|
+
console.log(runOnce
|
|
1123
|
+
? "[phoebe] Run-once mode — will work at most one unit of the first one-shot-eligible kind in WORK_ORDER, then exit."
|
|
1124
|
+
: `[phoebe] Persistent mode — idle poll every ${pollIntervalMs}ms.`);
|
|
1125
|
+
if (dryRun) {
|
|
1126
|
+
console.log("[phoebe] Dry-run — selection only, nothing executes.");
|
|
1127
|
+
}
|
|
1128
|
+
while (true) {
|
|
1129
|
+
exitForSelfUpdateIfNeeded();
|
|
1130
|
+
const fetchKinds = runOnce ? oneShotWorkKinds(workOrder) : workOrder;
|
|
1131
|
+
const data = await fetchCycleWorkData(fetchKinds);
|
|
1132
|
+
const picked = selectFirstWorkUnit(workOrder, {
|
|
1133
|
+
issues: data.issues,
|
|
1134
|
+
blockerStates: data.blockerStates,
|
|
1135
|
+
conflictingPrs: data.conflictingPrs,
|
|
1136
|
+
failingCheckPrs: data.failingCheckPrs,
|
|
1137
|
+
reviewActivityPrs: data.reviewActivityPrs,
|
|
1138
|
+
issueBodies: data.issueBodies,
|
|
1139
|
+
phoebeBase: process.env["PHOEBE_BASE"],
|
|
1140
|
+
phoebeLogin: data.phoebeLogin,
|
|
1141
|
+
currentMainHead: data.currentMainHead,
|
|
1142
|
+
}, { oneShotOnly: runOnce });
|
|
1143
|
+
if (!picked) {
|
|
1144
|
+
if (runOnce) {
|
|
1145
|
+
console.log(RUN_ONCE_NOTHING_MESSAGE);
|
|
1146
|
+
}
|
|
1147
|
+
else {
|
|
1148
|
+
logIdleCycle(data);
|
|
1149
|
+
}
|
|
1150
|
+
if (runOnce || dryRun)
|
|
1151
|
+
break;
|
|
1152
|
+
await sleep(pollIntervalMs);
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
const decision = executionDecision({ dryRun, inContainer });
|
|
1156
|
+
if (decision === "dry-run") {
|
|
1157
|
+
console.log(`[phoebe] Would execute: ${describeUnit(picked)}.`);
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
if (decision === "refuse") {
|
|
1161
|
+
console.error(EXECUTION_REFUSED_MESSAGE);
|
|
1162
|
+
process.exit(1);
|
|
1163
|
+
}
|
|
1164
|
+
try {
|
|
1165
|
+
await KINDS[picked.kind].runUnit(picked.unit);
|
|
1166
|
+
}
|
|
1167
|
+
catch (error) {
|
|
1168
|
+
if (runOnce) {
|
|
1169
|
+
throw error;
|
|
1170
|
+
}
|
|
1171
|
+
// A failed unit must not kill the daemon — prepareWorktree clears any
|
|
1172
|
+
// stale worktree on the next attempt.
|
|
1173
|
+
console.error(`[phoebe] Failed executing ${describeUnit(picked)} — ${error instanceof Error ? error.message : String(error)}`);
|
|
1174
|
+
await sleep(pollIntervalMs);
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
if (runOnce)
|
|
1178
|
+
break;
|
|
1179
|
+
}
|
|
1180
|
+
}
|