@ashulab/carrier 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +264 -2
- package/dist/chat/claude.js +367 -0
- package/dist/chat/codex.js +375 -0
- package/dist/chat/config.js +61 -0
- package/dist/chat/db.js +9 -0
- package/dist/chat/fake.js +203 -0
- package/dist/chat/folder.js +56 -0
- package/dist/chat/git.js +27 -0
- package/dist/chat/group.js +28 -0
- package/dist/chat/health.js +49 -0
- package/dist/chat/host.js +123 -0
- package/dist/chat/idle.js +20 -0
- package/dist/chat/migrations/0000_lame_quasar.sql +15 -0
- package/dist/chat/migrations/0001_quiet_penance.sql +23 -0
- package/dist/chat/migrations/0002_slim_ezekiel_stane.sql +1 -0
- package/dist/chat/migrations/0003_naive_iron_lad.sql +5 -0
- package/dist/chat/migrations/0004_skinny_dakota_north.sql +1 -0
- package/dist/chat/migrations/0005_tidy_strong_guy.sql +1 -0
- package/dist/chat/migrations/meta/0000_snapshot.json +121 -0
- package/dist/chat/migrations/meta/0001_snapshot.json +277 -0
- package/dist/chat/migrations/meta/0002_snapshot.json +284 -0
- package/dist/chat/migrations/meta/0003_snapshot.json +320 -0
- package/dist/chat/migrations/meta/0004_snapshot.json +327 -0
- package/dist/chat/migrations/meta/0005_snapshot.json +334 -0
- package/dist/chat/migrations/meta/_journal.json +48 -0
- package/dist/chat/names.js +97 -0
- package/dist/chat/plan.js +21 -0
- package/dist/chat/projects.js +242 -0
- package/dist/chat/runner.js +6 -0
- package/dist/chat/schema.js +48 -0
- package/dist/chat/server.js +202 -0
- package/dist/chat/sessions.js +766 -0
- package/dist/chat/sweep.js +64 -0
- package/dist/chat/transcript.js +31 -0
- package/dist/chat/words.js +20 -0
- package/dist/chat/workspace.js +134 -0
- package/dist/chat/worktree.js +142 -0
- package/dist/config/default-path.js +7 -0
- package/dist/config/engine-places.js +9 -0
- package/dist/config/json-schema.js +15 -0
- package/dist/config/load.js +48 -0
- package/dist/config/resolve.js +41 -0
- package/dist/config/schema.js +225 -0
- package/dist/control/epic.js +219 -0
- package/dist/control/events.js +48 -0
- package/dist/control/files.js +262 -0
- package/dist/control/replies.js +1 -0
- package/dist/control/server.js +237 -0
- package/dist/control/sessions.js +68 -0
- package/dist/doctor/doctor.js +255 -0
- package/dist/doctor/fixes.js +35 -0
- package/dist/execution/agent-runner.js +1 -0
- package/dist/execution/caller-result.js +61 -0
- package/dist/execution/events.js +19 -0
- package/dist/execution/failure.js +3 -0
- package/dist/execution/fake.js +82 -0
- package/dist/execution/git-workspaces.js +136 -0
- package/dist/execution/import-result.js +23 -0
- package/dist/execution/workspaces.js +1 -0
- package/dist/format/duration.js +12 -0
- package/dist/harnesses/agy.js +131 -0
- package/dist/harnesses/claude.js +246 -0
- package/dist/harnesses/codex.js +118 -0
- package/dist/harnesses/common.js +59 -0
- package/dist/harnesses/credentials.js +38 -0
- package/dist/harnesses/host-runner.js +225 -0
- package/dist/harnesses/local.js +25 -0
- package/dist/harnesses/names.js +9 -0
- package/dist/harnesses/replay-events.js +57 -0
- package/dist/integrations/available-repos.js +29 -0
- package/dist/integrations/forge-cwd.js +27 -0
- package/dist/integrations/github-graphql.js +216 -0
- package/dist/integrations/github.js +231 -0
- package/dist/logging/logger.js +53 -0
- package/dist/logging/redact.js +21 -0
- package/dist/orchestration/blockers.js +25 -0
- package/dist/orchestration/changes-slices.js +5 -0
- package/dist/orchestration/clock.js +7 -0
- package/dist/orchestration/engine.js +1068 -0
- package/dist/orchestration/fake.js +152 -0
- package/dist/orchestration/final-pr.js +15 -0
- package/dist/orchestration/github.js +31 -0
- package/dist/orchestration/reserve.js +32 -0
- package/dist/orchestration/resume-refused.js +7 -0
- package/dist/orchestration/retention.js +145 -0
- package/dist/orchestration/retry.js +16 -0
- package/dist/prompts/build-prompt.js +13 -0
- package/dist/prompts/fix.js +21 -0
- package/dist/prompts/intro.js +21 -0
- package/dist/prompts/invoke.js +2 -0
- package/dist/prompts/persona.js +12 -0
- package/dist/prompts/result.js +32 -0
- package/dist/prompts/review.js +15 -0
- package/dist/prompts/ship.js +8 -0
- package/dist/status/snapshot.js +92 -0
- package/dist/status/types.js +1 -0
- package/dist/storage/caller-results.js +7 -0
- package/dist/storage/db.js +8 -0
- package/dist/storage/epic-review-threads.js +19 -0
- package/dist/storage/epic-slices.js +17 -0
- package/dist/storage/events.js +52 -0
- package/dist/storage/executions.js +31 -0
- package/dist/storage/lock.js +21 -0
- package/dist/storage/migrations/0000_nosy_demogoblin.sql +5 -0
- package/dist/storage/migrations/0001_worried_grey_gargoyle.sql +3 -0
- package/dist/storage/migrations/0002_tough_squirrel_girl.sql +5 -0
- package/dist/storage/migrations/0003_loose_maria_hill.sql +4 -0
- package/dist/storage/migrations/0004_long_cerise.sql +18 -0
- package/dist/storage/migrations/0005_review_result.sql +2 -0
- package/dist/storage/migrations/0006_melted_menace.sql +10 -0
- package/dist/storage/migrations/0007_powerful_bloodscream.sql +2 -0
- package/dist/storage/migrations/0008_panel_state.sql +4 -0
- package/dist/storage/migrations/0009_workable_tiger_shark.sql +5 -0
- package/dist/storage/migrations/0010_nasty_blue_marvel.sql +4 -0
- package/dist/storage/migrations/0011_steep_lila_cheney.sql +13 -0
- package/dist/storage/migrations/0012_talented_big_bertha.sql +1 -0
- package/dist/storage/migrations/0013_third_mulholland_black.sql +17 -0
- package/dist/storage/migrations/0014_perfect_marten_broadcloak.sql +20 -0
- package/dist/storage/migrations/0015_long_bloodaxe.sql +1 -0
- package/dist/storage/migrations/0016_neat_true_believers.sql +5 -0
- package/dist/storage/migrations/0017_previous_swarm.sql +6 -0
- package/dist/storage/migrations/0018_opposite_plazm.sql +2 -0
- package/dist/storage/migrations/0019_hesitant_triton.sql +3 -0
- package/dist/storage/migrations/meta/0000_snapshot.json +49 -0
- package/dist/storage/migrations/meta/0001_snapshot.json +74 -0
- package/dist/storage/migrations/meta/0002_snapshot.json +105 -0
- package/dist/storage/migrations/meta/0003_snapshot.json +134 -0
- package/dist/storage/migrations/meta/0004_snapshot.json +257 -0
- package/dist/storage/migrations/meta/0005_snapshot.json +271 -0
- package/dist/storage/migrations/meta/0006_snapshot.json +337 -0
- package/dist/storage/migrations/meta/0007_snapshot.json +351 -0
- package/dist/storage/migrations/meta/0008_snapshot.json +379 -0
- package/dist/storage/migrations/meta/0009_snapshot.json +414 -0
- package/dist/storage/migrations/meta/0010_snapshot.json +442 -0
- package/dist/storage/migrations/meta/0011_snapshot.json +531 -0
- package/dist/storage/migrations/meta/0012_snapshot.json +538 -0
- package/dist/storage/migrations/meta/0013_snapshot.json +655 -0
- package/dist/storage/migrations/meta/0014_snapshot.json +772 -0
- package/dist/storage/migrations/meta/0015_snapshot.json +779 -0
- package/dist/storage/migrations/meta/0016_snapshot.json +793 -0
- package/dist/storage/migrations/meta/0017_snapshot.json +835 -0
- package/dist/storage/migrations/meta/0018_snapshot.json +849 -0
- package/dist/storage/migrations/meta/0019_snapshot.json +870 -0
- package/dist/storage/migrations/meta/_journal.json +146 -0
- package/dist/storage/schema.js +43 -0
- package/dist/terminal/cli/args.js +91 -0
- package/dist/terminal/cli/cli-error.js +11 -0
- package/dist/terminal/cli/commands/chat-host.js +18 -0
- package/dist/terminal/cli/commands/chat.js +321 -0
- package/dist/terminal/cli/commands/control.js +46 -0
- package/dist/terminal/cli/commands/doctor.js +15 -0
- package/dist/terminal/cli/commands/init.js +43 -0
- package/dist/terminal/cli/commands/restart.js +92 -0
- package/dist/terminal/cli/commands/run.js +20 -0
- package/dist/terminal/cli/commands/schema.js +4 -0
- package/dist/terminal/cli/commands/start.js +21 -0
- package/dist/terminal/cli/commands/status.js +61 -0
- package/dist/terminal/cli/commands/watch.js +112 -0
- package/dist/terminal/cli/compose.js +163 -0
- package/dist/terminal/cli/config-file.js +24 -0
- package/dist/terminal/cli/control-client.js +94 -0
- package/dist/terminal/cli/detached.js +41 -0
- package/dist/terminal/cli/interrupt.js +39 -0
- package/dist/terminal/cli/main.js +85 -0
- package/dist/terminal/cli/open-url.js +9 -0
- package/dist/terminal/dashboard/dashboard.js +204 -0
- package/dist/terminal/dashboard/history.js +38 -0
- package/dist/terminal/dashboard/interactive.js +60 -0
- package/dist/terminal/dashboard/keys.js +22 -0
- package/dist/terminal/dashboard/logs.js +85 -0
- package/dist/terminal/dashboard/panel.js +62 -0
- package/dist/terminal/dashboard/plain.js +32 -0
- package/dist/terminal/dashboard/printable.js +2 -0
- package/dist/terminal/dashboard/ui-state.js +110 -0
- package/dist/terminal/prompts/clack.js +121 -0
- package/dist/terminal/prompts/types.js +3 -0
- package/dist/terminal/wizard/build-config.js +66 -0
- package/dist/terminal/wizard/deps.js +94 -0
- package/dist/terminal/wizard/flags.js +1 -0
- package/dist/terminal/wizard/forge-registry.js +23 -0
- package/dist/terminal/wizard/prior.js +20 -0
- package/dist/terminal/wizard/questions/agent.js +61 -0
- package/dist/terminal/wizard/questions/identities.js +28 -0
- package/dist/terminal/wizard/questions/identity.js +30 -0
- package/dist/terminal/wizard/questions/options.js +93 -0
- package/dist/terminal/wizard/questions/out.js +16 -0
- package/dist/terminal/wizard/questions/persona.js +47 -0
- package/dist/terminal/wizard/questions/repos.js +61 -0
- package/dist/terminal/wizard/summary.js +31 -0
- package/dist/terminal/wizard/testing.js +106 -0
- package/dist/terminal/wizard/wizard.js +172 -0
- package/package.json +46 -3
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
5
|
+
import { and, asc, desc, eq, inArray, isNotNull, isNull, notInArray, sql } from 'drizzle-orm';
|
|
6
|
+
import { failoverOrderFor } from '../config/resolve.js';
|
|
7
|
+
import { failoverTriggers, matchesFailoverTrigger } from '../config/schema.js';
|
|
8
|
+
import { failureDetail } from '../execution/failure.js';
|
|
9
|
+
import { parseCallerResult } from '../execution/caller-result.js';
|
|
10
|
+
import { importResult } from '../execution/import-result.js';
|
|
11
|
+
import { epicReviewThreads } from '../storage/epic-review-threads.js';
|
|
12
|
+
import { epicSlices } from '../storage/epic-slices.js';
|
|
13
|
+
import { callerResults } from '../storage/caller-results.js';
|
|
14
|
+
import { epicEvents, recordEvents } from '../storage/events.js';
|
|
15
|
+
import { executions } from '../storage/executions.js';
|
|
16
|
+
import { executionOutput, taskContext } from '../logging/logger.js';
|
|
17
|
+
import { epics } from '../storage/schema.js';
|
|
18
|
+
import { latestBlockers, latestObservations } from './blockers.js';
|
|
19
|
+
import { changesSlices } from './changes-slices.js';
|
|
20
|
+
import { ResumeRefused } from './resume-refused.js';
|
|
21
|
+
import { endOf } from './final-pr.js';
|
|
22
|
+
import { AuthError, GitHubUnavailable, labelStyles, needsHumanLabel, ownedLabels, readyForTestLabel, readyLabel, runningLabel } from './github.js';
|
|
23
|
+
import { reserve } from './reserve.js';
|
|
24
|
+
import { startRetention } from './retention.js';
|
|
25
|
+
import { failureStreak } from './retry.js';
|
|
26
|
+
const log = createScopedLogger('engine');
|
|
27
|
+
const activityEveryMs = 10_000;
|
|
28
|
+
const resumeWaitMs = 30_000;
|
|
29
|
+
const resumeTickMs = 1_000;
|
|
30
|
+
// Slices read outside the poll: at most one read per epic in each window, and a timer as a net for what no event signals.
|
|
31
|
+
const sliceWindowMs = 10_000;
|
|
32
|
+
const sliceTimerMs = 60_000;
|
|
33
|
+
const liveStatuses = ['running', 'needs_human', 'ready_for_test'];
|
|
34
|
+
// Technical end of the process; whether the epic moves on depends on its result (retries and timeouts: later slices).
|
|
35
|
+
const sessionLost = 'the agent session is not available';
|
|
36
|
+
const noExit = { exitCode: null, signal: null, hostFreeMemMb: null, hostLoad: null };
|
|
37
|
+
const failureColumns = (f) => f && { failureKind: f.kind, failureDetail: f.detail, exitCode: f.exitCode, signal: f.signal, hostFreeMemMb: f.hostFreeMemMb, hostLoad: f.hostLoad };
|
|
38
|
+
const executionStatus = {
|
|
39
|
+
ok: 'finished',
|
|
40
|
+
failed: 'failed',
|
|
41
|
+
timed_out: 'timed_out',
|
|
42
|
+
stopped: 'interrupted',
|
|
43
|
+
auth_failed: 'failed',
|
|
44
|
+
session_unavailable: 'failed',
|
|
45
|
+
};
|
|
46
|
+
export function createEngine({ db, github, clock, repos, pollIntervalMs, maxConcurrentEpics, maxExecutionHours, maxExecutionRetries, maxReviews, optionChoiceSeconds, runner, workspaces, places, sessionDirs }) {
|
|
47
|
+
let error = null;
|
|
48
|
+
let nextPollAt = null;
|
|
49
|
+
let paused = false; // intake only: nothing new is reserved, what is running goes on
|
|
50
|
+
let polling = null;
|
|
51
|
+
// The orchestrator's profile failed: no GitHub call is made until the poll's probe succeeds. Never another profile.
|
|
52
|
+
let suspended = null;
|
|
53
|
+
let cancel = null;
|
|
54
|
+
let stopRetention = null;
|
|
55
|
+
const labelledRepos = new Set();
|
|
56
|
+
// Slice progress GitHub reported on the last good read, by repo and issue; not stored, so it is empty until the first poll after a start.
|
|
57
|
+
const progress = new Map();
|
|
58
|
+
// The last poll of each repo, in memory only: when, whether it worked and why not. Empty until the first poll after a start.
|
|
59
|
+
const checks = new Map();
|
|
60
|
+
// Runs of this process still going, to stop them when the epic ends from outside.
|
|
61
|
+
const active = new Map();
|
|
62
|
+
// Set by shutdown(): from then on nothing polls, reserves, launches or writes to GitHub.
|
|
63
|
+
let stopping = false;
|
|
64
|
+
// Creates the owned labels a repo lacks, with their colour and description; never touches existing ones nor creates `ready` (Solve's).
|
|
65
|
+
async function ensureLabels() {
|
|
66
|
+
for (const { name } of repos) {
|
|
67
|
+
if (stopping)
|
|
68
|
+
return;
|
|
69
|
+
if (labelledRepos.has(name))
|
|
70
|
+
continue;
|
|
71
|
+
const existing = await github.listLabels(name);
|
|
72
|
+
for (const label of ownedLabels)
|
|
73
|
+
if (!existing.includes(label))
|
|
74
|
+
await github.createLabel(name, label, labelStyles[label]);
|
|
75
|
+
labelledRepos.add(name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// The one owned label each status shows; merged and cancelled (stopped from outside) show none.
|
|
79
|
+
const labelOf = (status) => ({ running: runningLabel, needs_human: needsHumanLabel, ready_for_test: readyForTestLabel })[status];
|
|
80
|
+
// An epic a person sent back to the queue: nothing of the previous attempt is kept, the executions stay as history.
|
|
81
|
+
const restart = (queuedAt) => ({
|
|
82
|
+
status: 'queued', queuedAt, startedAt: null, config: null, labelPending: false, stage: null, stageStartedAt: null, workspace: null,
|
|
83
|
+
lastActivityAt: null, lastError: null, round: null, prNumber: null, prHeadSha: null, pr: null, reviewedSha: null, verification: null, observationsPassAt: null,
|
|
84
|
+
needsHumanReason: null, needsHumanKind: null, finishedAt: null, authorOption: null, reviewerOption: null, choiceDeadline: null,
|
|
85
|
+
});
|
|
86
|
+
// Mirrors the epic's status on its issue: its label, none of the other owned ones, and no `ready` while running.
|
|
87
|
+
const applyLabels = async ({ id, repo, issue, status }) => {
|
|
88
|
+
if (stopping || suspended)
|
|
89
|
+
return; // labelPending stays true: the next run reflects it
|
|
90
|
+
const wanted = labelOf(status);
|
|
91
|
+
await github.setLabels(repo, issue, {
|
|
92
|
+
add: wanted ? [wanted] : [],
|
|
93
|
+
remove: [...ownedLabels.filter((l) => l !== wanted), ...(status === 'running' ? [readyLabel] : [])],
|
|
94
|
+
});
|
|
95
|
+
db.update(epics).set({ labelPending: false }).where(eq(epics.id, id)).run();
|
|
96
|
+
};
|
|
97
|
+
const fresh = (id) => db.select().from(epics).where(eq(epics.id, id)).get();
|
|
98
|
+
// GitHub being down is expected (the next poll retries), and so is a failing orchestrator profile; anything else is a bug and surfaces.
|
|
99
|
+
const failed = (e) => {
|
|
100
|
+
if (e instanceof AuthError)
|
|
101
|
+
suspended = e;
|
|
102
|
+
else if (!(e instanceof GitHubUnavailable))
|
|
103
|
+
throw e;
|
|
104
|
+
error = e;
|
|
105
|
+
};
|
|
106
|
+
// The epic leaves `running` for `needs_human`; the label follows (labelPending) and the slot is free.
|
|
107
|
+
const toNeedsHuman = (writer, id, kind, reason) => writer.update(epics).set({ status: 'needs_human', needsHumanKind: kind, needsHumanReason: reason, finishedAt: clock.now(), labelPending: true }).where(eq(epics.id, id)).run();
|
|
108
|
+
// Mirrors the label, then launches the author unless the epic already has one or is waiting for its choice of options.
|
|
109
|
+
const begin = async (epic) => {
|
|
110
|
+
if (stopping)
|
|
111
|
+
return;
|
|
112
|
+
await applyLabels(epic);
|
|
113
|
+
if (epic.status !== 'running' || epic.stage !== null)
|
|
114
|
+
return;
|
|
115
|
+
const restarted = restarts.get(epic.id);
|
|
116
|
+
restarts.delete(epic.id);
|
|
117
|
+
if (restarted && epic.choiceDeadline !== null)
|
|
118
|
+
await startChosen(epic.id, declaredOnly(epic, restarted)); // already chosen at the restart: no wait
|
|
119
|
+
else if (epic.choiceDeadline === null)
|
|
120
|
+
await launch(epic, 'ship');
|
|
121
|
+
else
|
|
122
|
+
await awaitChoice(epic);
|
|
123
|
+
};
|
|
124
|
+
// The options a person picked when restarting a stopped epic, by epic id, until the poll that requeues it reserves it again.
|
|
125
|
+
// ponytail: in memory, an engine restart in between falls back to the defaults and the choice wait; persist it if that bites.
|
|
126
|
+
const restarts = new Map();
|
|
127
|
+
const declaredOnly = ({ id, config }, chosen) => Object.fromEntries(Object.entries(chosen).filter(([role, name]) => {
|
|
128
|
+
const declared = Object.hasOwn((config?.providers ?? config?.options) ?? {}, name);
|
|
129
|
+
if (!declared)
|
|
130
|
+
log.warn('the option chosen at the restart is not declared any more, the role starts on its default', { epicId: id, role, option: name });
|
|
131
|
+
return declared;
|
|
132
|
+
}));
|
|
133
|
+
// The timers of the epics waiting for their choice, by id.
|
|
134
|
+
const choiceTimers = new Map();
|
|
135
|
+
// Waits for `chooseOptions`, or for the deadline: a deadline already gone starts the epic on its defaults at once.
|
|
136
|
+
async function awaitChoice({ id, choiceDeadline }) {
|
|
137
|
+
if (choiceTimers.has(id))
|
|
138
|
+
return;
|
|
139
|
+
const wait = choiceDeadline - clock.now();
|
|
140
|
+
if (wait <= 0)
|
|
141
|
+
return void (await startChosen(id, {}));
|
|
142
|
+
choiceTimers.set(id, clock.after(wait, async () => {
|
|
143
|
+
try {
|
|
144
|
+
await startChosen(id, {});
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
log.error('could not start the epic at the end of its choice', e, { epicId: id });
|
|
148
|
+
}
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
// Ends the wait of a waiting epic and launches its author, on the options chosen for the roles and on the saved ones for the roles left out.
|
|
152
|
+
// False when the epic is not waiting (any more): the claim is one write, so only one caller gets it.
|
|
153
|
+
async function startChosen(id, chosen) {
|
|
154
|
+
choiceTimers.get(id)?.();
|
|
155
|
+
choiceTimers.delete(id);
|
|
156
|
+
if (stopping)
|
|
157
|
+
return false;
|
|
158
|
+
const { config, choiceDeadline } = fresh(id);
|
|
159
|
+
if (!config)
|
|
160
|
+
return false;
|
|
161
|
+
const roleOn = (role) => {
|
|
162
|
+
const name = chosen[role];
|
|
163
|
+
return name === undefined ? config.agents[role] : onOption(config, role, name);
|
|
164
|
+
};
|
|
165
|
+
const agents = { author: roleOn('author'), reviewer: roleOn('reviewer') };
|
|
166
|
+
const claimed = db
|
|
167
|
+
.update(epics)
|
|
168
|
+
.set({ config: { ...config, agents }, choiceDeadline: null, authorOption: agents.author.option ?? null, reviewerOption: agents.reviewer.option ?? null })
|
|
169
|
+
.where(and(eq(epics.id, id), eq(epics.status, 'running'), isNotNull(epics.choiceDeadline)))
|
|
170
|
+
.returning()
|
|
171
|
+
.get();
|
|
172
|
+
if (!claimed)
|
|
173
|
+
return false;
|
|
174
|
+
try {
|
|
175
|
+
await launch(claimed, 'ship');
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
// launch reads GitHub before it takes the stage: failing there leaves no agent and no wait. The wait is put back (the options
|
|
179
|
+
// chosen stay saved in the config), and resumeChoices starts the epic again on a later poll.
|
|
180
|
+
if (fresh(id).stage === null)
|
|
181
|
+
db.update(epics).set({ choiceDeadline }).where(eq(epics.id, id)).run();
|
|
182
|
+
throw e;
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
// Running epics whose wait for a choice has no timer: the start failed (GitHub was down) or a timer was lost. Past its deadline, each starts now.
|
|
187
|
+
async function resumeChoices() {
|
|
188
|
+
if (stopping || suspended)
|
|
189
|
+
return;
|
|
190
|
+
for (const epic of db.select().from(epics).where(and(eq(epics.status, 'running'), isNull(epics.stage), isNotNull(epics.choiceDeadline))).orderBy(asc(epics.id)).all())
|
|
191
|
+
await awaitChoice(epic);
|
|
192
|
+
}
|
|
193
|
+
const defaultHarnessModels = {
|
|
194
|
+
claude: 'sonnet',
|
|
195
|
+
codex: 'gpt-5.3-codex',
|
|
196
|
+
agy: 'gemini-3.8-flash-low',
|
|
197
|
+
};
|
|
198
|
+
// The role of the epic's saved config pointed at another of its saved options or providers: same profile and persona.
|
|
199
|
+
const onOption = (config, role, name) => {
|
|
200
|
+
const roleAgent = config.agents[role];
|
|
201
|
+
const { profile, persona, model } = roleAgent;
|
|
202
|
+
const provider = (config.providers ?? config.options)?.[name];
|
|
203
|
+
if (!provider)
|
|
204
|
+
throw new Error(`provider "${name}" is not declared in config.providers`);
|
|
205
|
+
const modelStr = provider.model ?? (provider.harness === roleAgent.harness ? model : defaultHarnessModels[provider.harness] ?? model);
|
|
206
|
+
return {
|
|
207
|
+
profile,
|
|
208
|
+
model: modelStr,
|
|
209
|
+
harness: provider.harness,
|
|
210
|
+
...(provider.provider && { provider: provider.provider }),
|
|
211
|
+
...(provider.configDir && { configDir: provider.configDir }),
|
|
212
|
+
...(persona && { persona }),
|
|
213
|
+
option: name,
|
|
214
|
+
providerName: name,
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
// With failover on, the role that ran out moves to the first option of `order` that has not run out in this epic and its stage runs
|
|
218
|
+
// again as a rebuilt session, no retry spent. `why` is what to add to the stop reason when it could not move.
|
|
219
|
+
async function failOver(epicId, stage, failedExecution) {
|
|
220
|
+
const epic = fresh(epicId);
|
|
221
|
+
const { config } = epic;
|
|
222
|
+
if (!config?.failover?.enabled)
|
|
223
|
+
return { relaunched: false, why: '' };
|
|
224
|
+
const triggers = config.failover.triggers ?? failoverTriggers;
|
|
225
|
+
if (!matchesFailoverTrigger(triggers, failedExecution.failureKind, failedExecution.failureDetail))
|
|
226
|
+
return { relaunched: false, why: '' };
|
|
227
|
+
const failoverKinds = [
|
|
228
|
+
...(triggers.includes('usage_limit') ? ['usage_limit'] : []),
|
|
229
|
+
...((triggers.includes('service_down') || triggers.includes('rate_limit')) ? ['api_error', 'spawn_error'] : []),
|
|
230
|
+
];
|
|
231
|
+
const ranOut = [...new Set(db.select({ option: executions.option }).from(executions).where(and(eq(executions.epicId, epicId), inArray(executions.failureKind, failoverKinds))).orderBy(asc(sql `rowid`)).all().flatMap(({ option }) => option ?? []))];
|
|
232
|
+
const role = stage === 'review' ? 'reviewer' : 'author';
|
|
233
|
+
const order = failoverOrderFor(config.failover, role) ?? config.failover.default ?? [];
|
|
234
|
+
const next = order.find((name) => !ranOut.includes(name));
|
|
235
|
+
if (next === undefined)
|
|
236
|
+
return { relaunched: false, why: `; no provider left in failover, ran out: ${ranOut.join(', ')}` };
|
|
237
|
+
if (stopping || epic.status !== 'running')
|
|
238
|
+
return { relaunched: true };
|
|
239
|
+
// Read only; GitHub being down is not a reason to stop: the agent verifies the state first anyway.
|
|
240
|
+
const found = await github.findPullRequest(epic.repo, epic.issue).catch((e) => (failed(e), null));
|
|
241
|
+
db.transaction((tx) => {
|
|
242
|
+
tx.update(epics).set({ config: { ...config, agents: { ...config.agents, [role]: onOption(config, role, next) } }, ...(role === 'author' ? { authorOption: next } : { reviewerOption: next }) }).where(eq(epics.id, epicId)).run();
|
|
243
|
+
tx.insert(epicEvents).values({ epicId, at: clock.now(), type: 'option_switched', data: { role, from: config.agents[role].option ?? null, to: next, stage } }).run();
|
|
244
|
+
});
|
|
245
|
+
await execute(fresh(epicId), stage, failedExecution, { pr: found && { number: found.number, headSha: found.headSha } });
|
|
246
|
+
return { relaunched: true };
|
|
247
|
+
}
|
|
248
|
+
// A stage ended on its usage limit: another option of the failover list, or the epic stops for a person. The reason says how to go on
|
|
249
|
+
// (the panel cuts long lines, so the way out comes before the agent's own detail, which carries the reset time).
|
|
250
|
+
async function onUsageLimit(epicId, execution) {
|
|
251
|
+
const moved = await failOver(epicId, execution.stage, execution);
|
|
252
|
+
if (moved.relaunched)
|
|
253
|
+
return;
|
|
254
|
+
const { repo, issue } = fresh(epicId);
|
|
255
|
+
const role = execution.stage === 'review' ? 'reviewer' : 'author';
|
|
256
|
+
const detail = execution.failureDetail ? ` · ${execution.failureDetail}` : '';
|
|
257
|
+
toNeedsHuman(db, epicId, 'usage_limit', `the ${execution.stage} execution ran out of usage on option ${execution.option ?? execution.harness} · carrier restart ${repo}#${issue} --${role} <option>${detail}${moved.why}`);
|
|
258
|
+
await freeSlot();
|
|
259
|
+
}
|
|
260
|
+
// Launches the author (ship, fix) or the reviewer (review) for the epic's current stage.
|
|
261
|
+
async function launch(epic, stage) {
|
|
262
|
+
if (stopping)
|
|
263
|
+
return;
|
|
264
|
+
let rebuild;
|
|
265
|
+
if (stage === 'ship') {
|
|
266
|
+
// An author on another option than the one that wrote the epic's session cannot continue it: a new session, rebuilt like a lost one.
|
|
267
|
+
// Read before the claim: if GitHub is down this throws with the stage still free (startChosen puts the wait back).
|
|
268
|
+
const before = lastAuthor(epic.id);
|
|
269
|
+
const optionNow = epic.config.agents.author.option ?? null;
|
|
270
|
+
if (before && (before.option ?? null) !== optionNow) {
|
|
271
|
+
const pr = await github.findPullRequest(epic.repo, epic.issue);
|
|
272
|
+
rebuild = { pr: pr && { number: pr.number, headSha: pr.headSha } };
|
|
273
|
+
}
|
|
274
|
+
// Claim the launch first: concurrent reconciles (a result freeing a slot, the poll) never start a second author.
|
|
275
|
+
const claimed = db.update(epics).set({ stage: 'ship', stageStartedAt: clock.now() }).where(and(eq(epics.id, epic.id), isNull(epics.stage))).returning().get();
|
|
276
|
+
if (!claimed)
|
|
277
|
+
return;
|
|
278
|
+
if (rebuild)
|
|
279
|
+
db.insert(epicEvents).values({ epicId: epic.id, at: clock.now(), type: 'session_rebuilt', data: { stage, from: before.option, to: optionNow, pr: rebuild.pr } }).run();
|
|
280
|
+
}
|
|
281
|
+
await execute(epic, stage, undefined, rebuild);
|
|
282
|
+
}
|
|
283
|
+
// Starts an execution of the stage; `retryOf` is the failed or interrupted one it replaces: same session, verify
|
|
284
|
+
// the real state first. `rebuild` (the session is gone) starts a new session instead, with the PR as GitHub has it now.
|
|
285
|
+
async function execute(epic, stage, retryOf, rebuild) {
|
|
286
|
+
const reviewing = stage === 'review';
|
|
287
|
+
const resumeSession = rebuild ? undefined : (retryOf?.sessionId ?? (stage === 'fix' ? authorSession(epic.id) : stage === 'ship' && !retryOf ? sameOptionSession(epic) : undefined));
|
|
288
|
+
const pr = rebuild?.pr ?? (epic.prNumber ? { number: epic.prNumber, headSha: epic.prHeadSha } : undefined);
|
|
289
|
+
let workspace;
|
|
290
|
+
try {
|
|
291
|
+
if (stage === 'fix') {
|
|
292
|
+
// Where `ship` left the epic's worktree: a session resumed anywhere else would act in another cwd.
|
|
293
|
+
if (!epic.workspace)
|
|
294
|
+
throw new Error('the epic has no saved worktree');
|
|
295
|
+
workspace = epic.workspace;
|
|
296
|
+
}
|
|
297
|
+
else
|
|
298
|
+
workspace = reviewing ? await workspaces.prepareReviewer(epic.repo, epic.issue, epic.prNumber) : await workspaces.prepareAuthor(epic.repo, epic.issue);
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
log.error('could not prepare the workspace', e, { repo: epic.repo, epic: epic.issue, stage });
|
|
302
|
+
toNeedsHuman(db, epic.id, 'workspace', `could not prepare the workspace: ${e instanceof Error ? e.message : String(e)}`);
|
|
303
|
+
return applyLabels(fresh(epic.id));
|
|
304
|
+
}
|
|
305
|
+
const executionId = randomUUID();
|
|
306
|
+
const resultPath = join(places.state, 'results', `${executionId}.json`);
|
|
307
|
+
// A fix right after a review that completed is the observations pass; after one that asked for changes, it answers the blockers.
|
|
308
|
+
const observations = stage === 'fix' ? latestObservations(db, epic.id) : null;
|
|
309
|
+
const task = {
|
|
310
|
+
executionId,
|
|
311
|
+
repo: epic.repo,
|
|
312
|
+
epic: epic.issue,
|
|
313
|
+
stage,
|
|
314
|
+
...(reviewing ? epic.config.agents.reviewer : epic.config.agents.author),
|
|
315
|
+
sharedIdentity: epic.config.sharedIdentity === true,
|
|
316
|
+
workspace,
|
|
317
|
+
resultPath,
|
|
318
|
+
timeoutMs: maxExecutionHours === 'unlimited' ? null : maxExecutionHours * 3_600_000,
|
|
319
|
+
...(resumeSession && { resumeSession }),
|
|
320
|
+
context: {
|
|
321
|
+
previousSummaries: reviewing || rebuild ? previousSummaries(epic.id) : [],
|
|
322
|
+
...(observations ? { observations } : (stage === 'fix' || (reviewing && (epic.round ?? 0) > 1)) && { blockers: latestBlockers(db, epic.id) }),
|
|
323
|
+
...(retryOf && { verifyStateFirst: true }),
|
|
324
|
+
...(pr && { pr }),
|
|
325
|
+
...(epic.round && { round: epic.round }),
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
if (stopping)
|
|
329
|
+
return;
|
|
330
|
+
db.insert(executions)
|
|
331
|
+
.values({ executionId, epicId: epic.id, stage, harness: task.harness, model: task.model, provider: task.provider ?? null, option: task.option ?? null, configDir: task.configDir ?? null, workspace, round: epic.round, resultPath, startedAt: clock.now() })
|
|
332
|
+
.run();
|
|
333
|
+
const context = taskContext({ repo: epic.repo, epic: epic.issue, executionId, stage, round: epic.round });
|
|
334
|
+
log.info('execution started', context);
|
|
335
|
+
const run = runner.run(task);
|
|
336
|
+
active.set(executionId, run);
|
|
337
|
+
const output = executionOutput(places.logs, executionId);
|
|
338
|
+
// The panel's "last activity": a SQLite write at most every 10 s, not one per chunk.
|
|
339
|
+
let activityAt = null;
|
|
340
|
+
run.onOutput((stream, chunk) => {
|
|
341
|
+
output.write(stream, chunk);
|
|
342
|
+
const now = clock.now();
|
|
343
|
+
if (activityAt !== null && now - activityAt < activityEveryMs)
|
|
344
|
+
return;
|
|
345
|
+
activityAt = now;
|
|
346
|
+
db.update(epics).set({ lastActivityAt: now }).where(eq(epics.id, epic.id)).run();
|
|
347
|
+
});
|
|
348
|
+
const sliceCalls = new Set(); // calls of this execution that started and have not ended; gone with it
|
|
349
|
+
// A failed insert must not break the run: the log still has everything, and the replay script can fill the rows.
|
|
350
|
+
run.onEvent((events) => {
|
|
351
|
+
try {
|
|
352
|
+
recordEvents(db, executionId, events, clock.now());
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
log.error('could not store the events of a chunk', e, context);
|
|
356
|
+
}
|
|
357
|
+
// A command that changes the slices is only a signal to look, and GitHub shows the change once it has ended, error or not.
|
|
358
|
+
for (const { kind, callId, arg } of events) {
|
|
359
|
+
if (callId === undefined)
|
|
360
|
+
continue;
|
|
361
|
+
if (kind === 'tool_start' && arg !== undefined && changesSlices(arg))
|
|
362
|
+
sliceCalls.add(callId);
|
|
363
|
+
else if (kind === 'tool_end' && sliceCalls.delete(callId))
|
|
364
|
+
readSlicesSoon(epic.id, epic.repo, epic.issue);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
run.onSession((sessionId) => db.update(executions).set({ sessionId }).where(eq(executions.executionId, executionId)).run());
|
|
368
|
+
track(run.done.then(async ({ exit, sessionId, failure: processFailure }) => {
|
|
369
|
+
active.delete(executionId);
|
|
370
|
+
output.flush();
|
|
371
|
+
(exit === 'ok' ? log.info : log.warn)('execution ended', { ...context, exit });
|
|
372
|
+
const failoverConfig = epic.config?.failover;
|
|
373
|
+
const triggers = failoverConfig?.enabled ? (failoverConfig.triggers ?? failoverTriggers) : [];
|
|
374
|
+
const isFailoverFailure = exit === 'failed' && processFailure?.kind !== undefined && matchesFailoverTrigger(triggers, processFailure.kind, processFailure.detail);
|
|
375
|
+
const outOfCredits = exit === 'failed' && processFailure?.kind === 'usage_limit';
|
|
376
|
+
// Like a timeout, failover leaves no error on the execution: it is not a retryable failure and ends the streak.
|
|
377
|
+
const failure = (outOfCredits || isFailoverFailure) ? undefined : { failed: `the agent process ended with an error (${processFailure?.kind ?? 'unknown'}${processFailure?.detail ? `: ${processFailure.detail}` : ''})`, auth_failed: `authentication error for profile ${task.profile}`, session_unavailable: sessionLost }[exit];
|
|
378
|
+
// The ends with their own handling carry no failure from the runner: their kind follows from the exit.
|
|
379
|
+
const endFailure = {
|
|
380
|
+
failed: { kind: 'unknown', detail: '' },
|
|
381
|
+
auth_failed: { kind: 'auth', detail: `profile ${task.profile}` },
|
|
382
|
+
session_unavailable: { kind: 'session_lost', detail: sessionLost },
|
|
383
|
+
timed_out: { kind: 'timeout', detail: `exceeded ${maxExecutionHours} h` },
|
|
384
|
+
};
|
|
385
|
+
db.update(executions)
|
|
386
|
+
.set({ status: executionStatus[exit], finishedAt: clock.now(), ...(sessionId && { sessionId }), ...(failure && { error: failure }), ...failureColumns(processFailure ?? (endFailure[exit] && { ...noExit, ...endFailure[exit] })) })
|
|
387
|
+
.where(eq(executions.executionId, executionId))
|
|
388
|
+
.run();
|
|
389
|
+
if (failure)
|
|
390
|
+
db.update(epics).set({ lastError: failure }).where(eq(epics.id, epic.id)).run();
|
|
391
|
+
if (exit === 'ok')
|
|
392
|
+
await settle(db.select().from(executions).where(eq(executions.executionId, executionId)).get());
|
|
393
|
+
else if (isFailoverFailure || outOfCredits) {
|
|
394
|
+
// Retrying the same option only burns the retries.
|
|
395
|
+
await onUsageLimit(epic.id, db.select().from(executions).where(eq(executions.executionId, executionId)).get());
|
|
396
|
+
}
|
|
397
|
+
else if (exit === 'failed' || exit === 'session_unavailable')
|
|
398
|
+
await retry(epic.id);
|
|
399
|
+
else if (exit === 'auth_failed')
|
|
400
|
+
await stopProfile(task.profile);
|
|
401
|
+
else if (exit === 'timed_out') {
|
|
402
|
+
// Session and workspace stay as they are; a person decides how to go on.
|
|
403
|
+
toNeedsHuman(db, epic.id, 'timeout', `the ${stage} execution exceeded the time limit`);
|
|
404
|
+
await freeSlot();
|
|
405
|
+
}
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
// Forge rejected a profile: the running epics that use it stop for a person, with their runs; the others go on.
|
|
409
|
+
// The profile is never swapped for another one.
|
|
410
|
+
async function stopProfile(profile) {
|
|
411
|
+
for (const epic of db.select().from(epics).where(eq(epics.status, 'running')).orderBy(asc(epics.id)).all()) {
|
|
412
|
+
const { author, reviewer } = epic.config.agents;
|
|
413
|
+
if (author.profile !== profile && reviewer.profile !== profile)
|
|
414
|
+
continue;
|
|
415
|
+
toNeedsHuman(db, epic.id, 'auth', `authentication error for profile ${profile}`);
|
|
416
|
+
const running = db.select().from(executions).where(and(eq(executions.epicId, epic.id), eq(executions.status, 'running'))).all();
|
|
417
|
+
await Promise.all(running.map(({ executionId }) => active.get(executionId)?.stop()));
|
|
418
|
+
}
|
|
419
|
+
await freeSlot();
|
|
420
|
+
}
|
|
421
|
+
// Relaunches the stage whose last execution failed technically, until the retries run out.
|
|
422
|
+
const retrying = new Set();
|
|
423
|
+
async function retry(epicId) {
|
|
424
|
+
if (stopping || suspended || retrying.has(epicId))
|
|
425
|
+
return;
|
|
426
|
+
retrying.add(epicId);
|
|
427
|
+
try {
|
|
428
|
+
const epic = fresh(epicId);
|
|
429
|
+
const [last, ...earlier] = failureStreak(db, epicId);
|
|
430
|
+
if (epic.status !== 'running' || !last)
|
|
431
|
+
return;
|
|
432
|
+
if (earlier.length >= maxExecutionRetries) {
|
|
433
|
+
toNeedsHuman(db, epicId, 'retries_exhausted', `ran out of ${last.stage} retries (${earlier.length + 1} attempts): ${last.error}`);
|
|
434
|
+
return await freeSlot();
|
|
435
|
+
}
|
|
436
|
+
// Read only: what the failed execution may have left on GitHub. A failed query is not an attempt, the poll tries again.
|
|
437
|
+
const pr = await github.findPullRequest(epic.repo, epic.issue);
|
|
438
|
+
// A lost session is rebuilt, not resumed: new session, same profile, context from SQLite and GitHub.
|
|
439
|
+
const lost = last.failureKind === 'session_lost';
|
|
440
|
+
db.insert(epicEvents)
|
|
441
|
+
.values({ epicId, at: clock.now(), type: lost ? 'session_rebuilt' : 'execution_retried', data: { stage: last.stage, failed: last.executionId, attempt: earlier.length + 2, error: last.error, pr } })
|
|
442
|
+
.run();
|
|
443
|
+
await execute(epic, last.stage, last, lost ? { pr: pr && { number: pr.number, headSha: pr.headSha } } : undefined);
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
retrying.delete(epicId);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Running epics whose last execution failed and was not relaunched yet (GitHub was down).
|
|
450
|
+
async function retryPending() {
|
|
451
|
+
for (const { id } of db.select({ id: epics.id }).from(epics).where(eq(epics.status, 'running')).orderBy(asc(epics.id)).all())
|
|
452
|
+
await retry(id);
|
|
453
|
+
}
|
|
454
|
+
// Summaries the caller left in earlier review and fix turns, oldest first.
|
|
455
|
+
const previousSummaries = (epicId) => db
|
|
456
|
+
.select({ result: callerResults.result })
|
|
457
|
+
.from(callerResults)
|
|
458
|
+
.innerJoin(executions, eq(executions.executionId, callerResults.executionId))
|
|
459
|
+
.where(and(eq(executions.epicId, epicId), inArray(executions.stage, ['review', 'fix'])))
|
|
460
|
+
.orderBy(asc(executions.startedAt))
|
|
461
|
+
.all()
|
|
462
|
+
.map(({ result }) => JSON.parse(result).summary);
|
|
463
|
+
// Where the author left off, and the option that wrote it.
|
|
464
|
+
const lastAuthor = (epicId) => db
|
|
465
|
+
.select({ sessionId: executions.sessionId, option: executions.option })
|
|
466
|
+
.from(executions)
|
|
467
|
+
.where(and(eq(executions.epicId, epicId), inArray(executions.stage, ['ship', 'fix']), isNotNull(executions.sessionId)))
|
|
468
|
+
.orderBy(desc(sql `rowid`))
|
|
469
|
+
.get();
|
|
470
|
+
// The fix turn resumes it.
|
|
471
|
+
const authorSession = (epicId) => lastAuthor(epicId)?.sessionId ?? undefined;
|
|
472
|
+
// A ship that starts again continues the author's session only on the option that wrote it.
|
|
473
|
+
const sameOptionSession = (epic) => {
|
|
474
|
+
const before = lastAuthor(epic.id);
|
|
475
|
+
return before && (before.option ?? null) === (epic.config.agents.author.option ?? null) ? (before.sessionId ?? undefined) : undefined;
|
|
476
|
+
};
|
|
477
|
+
const inflight = new Set();
|
|
478
|
+
const track = (work) => {
|
|
479
|
+
const tracked = work
|
|
480
|
+
.catch((e) => {
|
|
481
|
+
log.error('stage work failed', e);
|
|
482
|
+
failed(e);
|
|
483
|
+
})
|
|
484
|
+
.finally(() => inflight.delete(tracked));
|
|
485
|
+
inflight.add(tracked);
|
|
486
|
+
};
|
|
487
|
+
// Does the final PR the caller reported hold up against what GitHub says? Returns why not, or null.
|
|
488
|
+
function prProblem({ issue }, found, pr, stage) {
|
|
489
|
+
if (!found)
|
|
490
|
+
return `PR #${pr.number} does not exist`;
|
|
491
|
+
if (found.state !== 'open')
|
|
492
|
+
return `PR #${pr.number} is not open`;
|
|
493
|
+
if (!new RegExp(`\\bcloses #${issue}(?!\\d)`, 'i').test(found.body))
|
|
494
|
+
return `PR #${pr.number} does not reference the epic (missing "Closes #${issue}")`;
|
|
495
|
+
if (found.headSha !== pr.headSha) {
|
|
496
|
+
return stage === 'review' ? 'the head changed during the review' : `the head of PR #${pr.number} is ${found.headSha}, not ${pr.headSha}`;
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
const expectedOf = (epic, execution) => ({ executionId: execution.executionId, repo: epic.repo, epic: epic.issue, stage: execution.stage });
|
|
501
|
+
// The file the caller left, null when there is none (the parser reports it).
|
|
502
|
+
function readResult({ resultPath }) {
|
|
503
|
+
try {
|
|
504
|
+
return readFileSync(resultPath, 'utf8');
|
|
505
|
+
}
|
|
506
|
+
catch (e) {
|
|
507
|
+
if (e.code !== 'ENOENT')
|
|
508
|
+
throw e;
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Imports the result of a finished execution and moves the epic. Safe to repeat: the import is idempotent.
|
|
513
|
+
async function settle(execution) {
|
|
514
|
+
if (stopping || suspended)
|
|
515
|
+
return; // stays `finished` without a result: the next run imports it
|
|
516
|
+
const epic = fresh(execution.epicId);
|
|
517
|
+
if (epic.status === 'merged' || epic.status === 'cancelled' || epic.status === 'repo_removed')
|
|
518
|
+
return; // ended from outside or stopped: nothing new moves
|
|
519
|
+
const expected = expectedOf(epic, execution);
|
|
520
|
+
const raw = readResult(execution);
|
|
521
|
+
const parsed = parseCallerResult(raw, expected);
|
|
522
|
+
const context = taskContext({ repo: epic.repo, epic: epic.issue, executionId: execution.executionId, stage: execution.stage, round: epic.round });
|
|
523
|
+
if (!parsed.ok) {
|
|
524
|
+
log.warn('invalid caller result', { ...context, reason: parsed.message });
|
|
525
|
+
db.update(executions).set({ status: 'invalid_result', error: parsed.message, ...failureColumns({ ...noExit, kind: 'invalid_result', detail: failureDetail(parsed.message) }) }).where(eq(executions.executionId, execution.executionId)).run();
|
|
526
|
+
db.update(epics).set({ lastError: parsed.message }).where(eq(epics.id, epic.id)).run();
|
|
527
|
+
return retry(epic.id);
|
|
528
|
+
}
|
|
529
|
+
let problem = null;
|
|
530
|
+
let workspace;
|
|
531
|
+
let branch; // the final PR's branch
|
|
532
|
+
if (parsed.result.outcome !== 'needs_human') {
|
|
533
|
+
const { pr } = parsed.result;
|
|
534
|
+
const found = await github.getPullRequest(epic.repo, pr.number);
|
|
535
|
+
const invalid = prProblem(epic, found, pr, execution.stage);
|
|
536
|
+
if (invalid)
|
|
537
|
+
problem = { kind: 'pr_invalid', text: invalid };
|
|
538
|
+
if (!problem && execution.stage === 'ship') {
|
|
539
|
+
// Solve put the epic's worktree where its solve.md says: read it back from git, keep the real path for the fix turns.
|
|
540
|
+
branch = found.headRef;
|
|
541
|
+
try {
|
|
542
|
+
workspace = (await workspaces.findWorktree(epic.repo, branch)) ?? undefined;
|
|
543
|
+
}
|
|
544
|
+
catch (e) {
|
|
545
|
+
log.error('could not list the worktrees', e, context);
|
|
546
|
+
}
|
|
547
|
+
if (!workspace)
|
|
548
|
+
problem = { kind: 'workspace', text: `could not find the worktree for branch ${branch} in the clone of ${epic.repo}` };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const imported = importResult(db, raw, expected, (tx, result) => {
|
|
552
|
+
const update = (set) => tx.update(epics).set(set).where(eq(epics.id, epic.id)).run();
|
|
553
|
+
if (result.outcome === 'needs_human')
|
|
554
|
+
toNeedsHuman(tx, epic.id, 'agent_needs_human', result.reason);
|
|
555
|
+
else if (problem)
|
|
556
|
+
toNeedsHuman(tx, epic.id, problem.kind, problem.text);
|
|
557
|
+
else if (execution.stage === 'ship')
|
|
558
|
+
update({ stage: 'review', stageStartedAt: clock.now(), round: 1, prNumber: result.pr.number, prHeadSha: result.pr.headSha, workspace, branch });
|
|
559
|
+
// The pass ended with the head that was reviewed: nothing to review again, the reviewer's verification stands.
|
|
560
|
+
else if (execution.stage === 'fix' && latestObservations(db, epic.id) && result.pr.headSha === epic.reviewedSha)
|
|
561
|
+
update({ status: 'ready_for_test', finishedAt: clock.now(), labelPending: true });
|
|
562
|
+
else if (execution.stage === 'fix')
|
|
563
|
+
update({ stage: 'review', stageStartedAt: clock.now(), round: (epic.round ?? 0) + 1, prHeadSha: result.pr.headSha });
|
|
564
|
+
// The round stays: the fix turn answers it, and the next review numbers the following one.
|
|
565
|
+
else if (result.outcome === 'changes_requested') {
|
|
566
|
+
if ((epic.round ?? 0) >= maxReviews)
|
|
567
|
+
toNeedsHuman(tx, epic.id, 'review_limit', `review limit: round ${epic.round} of ${maxReviews} still has requested changes`);
|
|
568
|
+
else
|
|
569
|
+
update({ stage: 'fix', stageStartedAt: clock.now() });
|
|
570
|
+
}
|
|
571
|
+
// The one observations pass: the author answers them on the same round, and the reviewed head and verification wait for how it ends.
|
|
572
|
+
else if (result.outcome === 'completed' && result.observations?.length && epic.observationsPassAt === null && (epic.round ?? 0) < maxReviews) {
|
|
573
|
+
update({ stage: 'fix', stageStartedAt: clock.now(), observationsPassAt: clock.now(), reviewedSha: result.pr.headSha, verification: result.verification });
|
|
574
|
+
}
|
|
575
|
+
else
|
|
576
|
+
update({ status: 'ready_for_test', reviewedSha: result.pr.headSha, verification: result.verification, finishedAt: clock.now(), labelPending: true });
|
|
577
|
+
});
|
|
578
|
+
if (!('duplicate' in imported))
|
|
579
|
+
db.update(epics).set({ lastError: null }).where(eq(epics.id, epic.id)).run();
|
|
580
|
+
log.info('result imported', { ...context, outcome: parsed.result.outcome, duplicate: 'duplicate' in imported });
|
|
581
|
+
const next = fresh(epic.id);
|
|
582
|
+
// ship and fix hand over to the reviewer, a review with changes hands over to the author.
|
|
583
|
+
if (!('duplicate' in imported) && next.status === 'running' && next.stage && next.stage !== 'ship')
|
|
584
|
+
await launch(next, next.stage);
|
|
585
|
+
await freeSlot();
|
|
586
|
+
}
|
|
587
|
+
// A result arrived: reflect it on the issue and give the freed slot to the next epic without waiting for the poll.
|
|
588
|
+
async function freeSlot() {
|
|
589
|
+
await reconcileLabels();
|
|
590
|
+
await fillSlots();
|
|
591
|
+
}
|
|
592
|
+
// The final PR was merged or the epic issue / final PR closed without merge: the epic stops and its
|
|
593
|
+
// executions too. Sessions and history stay; the label follows (labelPending) and the slot is free.
|
|
594
|
+
// Parked epics (needs_human, ready_for_test) end the same way: a person merges or closes outside Carrier.
|
|
595
|
+
async function stopEnded() {
|
|
596
|
+
for (const epic of db.select().from(epics).where(and(inArray(epics.status, ['running', 'needs_human', 'ready_for_test']), inArray(epics.repo, repos.map((r) => r.name)))).orderBy(asc(epics.id)).all()) {
|
|
597
|
+
const end = await endOf(github, epic);
|
|
598
|
+
if (!end)
|
|
599
|
+
continue;
|
|
600
|
+
db.update(epics)
|
|
601
|
+
.set({ status: end.status, ...(end.status === 'cancelled' && { cancelReason: end.reason }), finishedAt: clock.now(), labelPending: true })
|
|
602
|
+
.where(eq(epics.id, epic.id))
|
|
603
|
+
.run();
|
|
604
|
+
db.insert(epicEvents).values({ epicId: epic.id, at: clock.now(), type: `epic_${end.status}`, data: { reason: end.reason } }).run();
|
|
605
|
+
const running = db.select().from(executions).where(and(eq(executions.epicId, epic.id), eq(executions.status, 'running'))).all();
|
|
606
|
+
await Promise.all(running.map(({ executionId }) => active.get(executionId)?.stop()));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// Finished executions whose result could not be imported yet (GitHub was down).
|
|
610
|
+
async function settlePending() {
|
|
611
|
+
const pending = db
|
|
612
|
+
.select({ execution: executions })
|
|
613
|
+
.from(executions)
|
|
614
|
+
.leftJoin(callerResults, eq(callerResults.executionId, executions.executionId))
|
|
615
|
+
.where(and(eq(executions.status, 'finished'), isNull(callerResults.executionId)))
|
|
616
|
+
.orderBy(asc(executions.startedAt))
|
|
617
|
+
.all();
|
|
618
|
+
for (const { execution } of pending)
|
|
619
|
+
await settle(execution);
|
|
620
|
+
}
|
|
621
|
+
// SQLite is the authority: a label that did not reach GitHub is retried here.
|
|
622
|
+
async function reconcileLabels() {
|
|
623
|
+
const pending = db
|
|
624
|
+
.select()
|
|
625
|
+
.from(epics)
|
|
626
|
+
.where(and(inArray(epics.status, ['running', 'needs_human', 'ready_for_test', 'merged', 'cancelled']), eq(epics.labelPending, true)))
|
|
627
|
+
.orderBy(asc(epics.id))
|
|
628
|
+
.all();
|
|
629
|
+
for (const epic of pending)
|
|
630
|
+
await begin(epic);
|
|
631
|
+
}
|
|
632
|
+
// Starts the oldest queued epics until the concurrency limit or the queue is exhausted.
|
|
633
|
+
async function fillSlots() {
|
|
634
|
+
for (;;) {
|
|
635
|
+
if (stopping || suspended || paused)
|
|
636
|
+
return;
|
|
637
|
+
const epic = reserve(db, { maxConcurrent: maxConcurrentEpics, now: clock.now(), repos, choiceMs: optionChoiceSeconds * 1000 });
|
|
638
|
+
if (!epic)
|
|
639
|
+
return;
|
|
640
|
+
const cancelWith = (cancelReason) => db.update(epics).set({ status: 'cancelled', cancelReason, finishedAt: clock.now(), labelPending: false }).where(eq(epics.id, epic.id)).run();
|
|
641
|
+
let issue;
|
|
642
|
+
try {
|
|
643
|
+
issue = await github.getIssue(epic.repo, epic.issue);
|
|
644
|
+
}
|
|
645
|
+
catch (e) {
|
|
646
|
+
// Could not check it: give the slot back, the epic keeps its place in the queue.
|
|
647
|
+
db.update(epics).set({ status: 'queued', startedAt: null, config: null, labelPending: false, authorOption: null, reviewerOption: null, choiceDeadline: null }).where(eq(epics.id, epic.id)).run();
|
|
648
|
+
throw e;
|
|
649
|
+
}
|
|
650
|
+
if (issue.state === 'closed')
|
|
651
|
+
cancelWith('closed before it started');
|
|
652
|
+
else if (!issue.labels.includes(readyLabel))
|
|
653
|
+
cancelWith('ready label removed');
|
|
654
|
+
else {
|
|
655
|
+
// Its slices exist before it is ready: read them now so the app has the list from the first second, not after the next poll.
|
|
656
|
+
await readEpicSlices(epic.repo, epic.id, epic.issue);
|
|
657
|
+
await begin(epic); // GitHubUnavailable here leaves it reserved, labelPending
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
// Restart, before anything is reserved: stop what the previous process left alive, then continue each running
|
|
662
|
+
// epic from where it was. Safe to repeat (GitHub may be down halfway): what it relaunched is `active` or running.
|
|
663
|
+
let recovered = false;
|
|
664
|
+
async function recover() {
|
|
665
|
+
// Orphans first: no second agent may start on a workspace while the first still acts on it. An orphan is an execution
|
|
666
|
+
// this engine folder's SQLite launched: the process list is the machine's, and a live agent of another carrier is not ours to stop.
|
|
667
|
+
const ownExecution = (id) => db.select({ id: executions.executionId }).from(executions).where(eq(executions.executionId, id)).get() !== undefined;
|
|
668
|
+
for (const id of await runner.listActive())
|
|
669
|
+
if (!active.has(id) && ownExecution(id)) {
|
|
670
|
+
log.warn('stopping orphan execution', { executionId: id });
|
|
671
|
+
await runner.stop(id);
|
|
672
|
+
}
|
|
673
|
+
for (const { executionId } of db.select().from(executions).where(eq(executions.status, 'running')).all()) {
|
|
674
|
+
if (active.has(executionId))
|
|
675
|
+
continue;
|
|
676
|
+
db.update(executions).set({ status: 'interrupted', finishedAt: clock.now() }).where(eq(executions.executionId, executionId)).run();
|
|
677
|
+
}
|
|
678
|
+
// A repo missing from the config stops its pending epics, untouched, with where they were; a repo back resumes them
|
|
679
|
+
// there (a `needs_human` one stays stopped). No GitHub call, label change, execution or retry for either.
|
|
680
|
+
const configured = repos.map((r) => r.name);
|
|
681
|
+
const now = clock.now();
|
|
682
|
+
for (const epic of db.select().from(epics).where(and(inArray(epics.status, ['queued', 'running', 'needs_human']), notInArray(epics.repo, configured))).all()) {
|
|
683
|
+
const reason = 'the repo is no longer in the configuration';
|
|
684
|
+
db.update(epics).set({ status: 'repo_removed', removedFrom: epic.status, removedReason: reason }).where(eq(epics.id, epic.id)).run();
|
|
685
|
+
db.insert(epicEvents).values({ epicId: epic.id, at: now, type: 'repo_removed', data: { from: epic.status, reason } }).run();
|
|
686
|
+
}
|
|
687
|
+
for (const epic of db.select().from(epics).where(and(eq(epics.status, 'repo_removed'), inArray(epics.repo, configured))).all()) {
|
|
688
|
+
db.update(epics).set({ status: epic.removedFrom, removedFrom: null, removedReason: null }).where(eq(epics.id, epic.id)).run();
|
|
689
|
+
db.insert(epicEvents).values({ epicId: epic.id, at: now, type: 'repo_restored', data: { to: epic.removedFrom } }).run();
|
|
690
|
+
}
|
|
691
|
+
for (const { id } of db.select({ id: epics.id }).from(epics).where(eq(epics.status, 'running')).orderBy(asc(epics.id)).all()) {
|
|
692
|
+
const epic = fresh(id);
|
|
693
|
+
const last = db.select().from(executions).where(eq(executions.epicId, id)).orderBy(desc(sql `rowid`)).get();
|
|
694
|
+
log.info('recovering epic', { repo: epic.repo, epic: epic.issue, stage: epic.stage, lastStatus: last?.status });
|
|
695
|
+
if (!last && epic.choiceDeadline !== null) {
|
|
696
|
+
await awaitChoice(epic); // still waiting for its choice, or past the deadline: the defaults
|
|
697
|
+
}
|
|
698
|
+
else if (!last) {
|
|
699
|
+
// Stopped between claiming the launch and inserting the execution: no agent ever ran, so launching is not a blind restart.
|
|
700
|
+
db.update(epics).set({ stage: null }).where(eq(epics.id, id)).run();
|
|
701
|
+
await launch(fresh(id), 'ship');
|
|
702
|
+
}
|
|
703
|
+
else if (last.status === 'interrupted') {
|
|
704
|
+
// The agent may have written its result just before the cut: import it once instead of launching again.
|
|
705
|
+
if (parseCallerResult(readResult(last), expectedOf(epic, last)).ok) {
|
|
706
|
+
db.update(executions).set({ status: 'finished' }).where(eq(executions.executionId, last.executionId)).run();
|
|
707
|
+
await settle({ ...last, status: 'finished' });
|
|
708
|
+
}
|
|
709
|
+
else
|
|
710
|
+
await execute(epic, last.stage, last);
|
|
711
|
+
}
|
|
712
|
+
else if (epic.stage !== null &&
|
|
713
|
+
last.status === 'failed' &&
|
|
714
|
+
(last.failureKind === 'usage_limit' ||
|
|
715
|
+
(epic.config?.failover?.enabled &&
|
|
716
|
+
matchesFailoverTrigger(epic.config.failover.triggers ?? failoverTriggers, last.failureKind, last.failureDetail)))) {
|
|
717
|
+
// Stopped before the epic moved on: no agent is running and none would be launched. A requeued epic (no stage yet) keeps its old
|
|
718
|
+
// executions: that history is not this stop, the restart's own launch (begin, resumeChoices) takes it from here.
|
|
719
|
+
await onUsageLimit(id, last);
|
|
720
|
+
}
|
|
721
|
+
else if (last.status === 'finished' && epic.stage && db.select().from(callerResults).where(eq(callerResults.executionId, last.executionId)).get()) {
|
|
722
|
+
await launch(epic, epic.stage); // imported, but the next stage was never launched
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// SQLite is the authority: labels that differ on GitHub are put back.
|
|
726
|
+
for (const epic of db.select().from(epics).where(and(inArray(epics.status, ['running', 'needs_human', 'ready_for_test']), inArray(epics.repo, configured))).orderBy(asc(epics.id)).all()) {
|
|
727
|
+
const { labels } = await github.getIssue(epic.repo, epic.issue);
|
|
728
|
+
const wanted = labelOf(epic.status);
|
|
729
|
+
if (ownedLabels.some((l) => labels.includes(l) !== (l === wanted)) || (epic.status === 'running' && labels.includes(readyLabel)))
|
|
730
|
+
await applyLabels(epic);
|
|
731
|
+
}
|
|
732
|
+
recovered = true;
|
|
733
|
+
}
|
|
734
|
+
// A failed read keeps the last known values and the poll goes on: progress is only for show. A rejected profile is not "only for show".
|
|
735
|
+
async function refreshProgress(repo) {
|
|
736
|
+
try {
|
|
737
|
+
const open = await github.listOpenEpics(repo);
|
|
738
|
+
progress.set(repo, new Map(open.map(({ issue, slices }) => [issue, slices])));
|
|
739
|
+
// The same read carries the title and labels of the epics Carrier already holds, whatever their status.
|
|
740
|
+
for (const { issue, title, labels } of open)
|
|
741
|
+
db.update(epics).set({ title, labels }).where(and(eq(epics.repo, repo), eq(epics.issue, issue))).run();
|
|
742
|
+
}
|
|
743
|
+
catch (e) {
|
|
744
|
+
if (e instanceof AuthError)
|
|
745
|
+
throw e;
|
|
746
|
+
log.error('slice progress read failed, keeping the last values', e);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// The one refresh rule of the caches of GitHub (ADR 0008), shared by the slices and the pull request block: live epics (running, parked)
|
|
750
|
+
// are read every poll; a finished one keeps what it has and is read once only when it has nothing cached, e.g. it ended before the cache existed.
|
|
751
|
+
function toRefresh(repo, hasCache) {
|
|
752
|
+
return db.select({ id: epics.id, issue: epics.issue, status: epics.status, prNumber: epics.prNumber, pr: epics.pr }).from(epics).where(eq(epics.repo, repo)).orderBy(asc(epics.id)).all()
|
|
753
|
+
.filter((e) => liveStatuses.includes(e.status) || (['merged', 'cancelled'].includes(e.status) && !hasCache(e)));
|
|
754
|
+
}
|
|
755
|
+
// The slices of each epic to refresh, replacing its rows. A cache of GitHub: nothing the engine decides reads it back.
|
|
756
|
+
// A failed read keeps the last rows and the poll goes on.
|
|
757
|
+
// One read at a time per epic, whoever asks (the poll, an event, the timer): a read waits for the one in flight, so it starts after
|
|
758
|
+
// the other has written and an older answer never lands over a newer one.
|
|
759
|
+
const sliceReads = new Map();
|
|
760
|
+
function readEpicSlices(repo, id, issue) {
|
|
761
|
+
const inFlight = sliceReads.get(id);
|
|
762
|
+
const read = inFlight ? inFlight.then(() => readSlicesNow(repo, id, issue)) : readSlicesNow(repo, id, issue);
|
|
763
|
+
const settled = read.catch(() => { });
|
|
764
|
+
sliceReads.set(id, settled);
|
|
765
|
+
void settled.then(() => { if (sliceReads.get(id) === settled)
|
|
766
|
+
sliceReads.delete(id); });
|
|
767
|
+
return read;
|
|
768
|
+
}
|
|
769
|
+
async function readSlicesNow(repo, id, issue) {
|
|
770
|
+
try {
|
|
771
|
+
const { slices, truncated } = await github.readSlices(repo, issue);
|
|
772
|
+
const readAt = clock.now();
|
|
773
|
+
db.transaction((tx) => {
|
|
774
|
+
tx.delete(epicSlices).where(eq(epicSlices.epicId, id)).run();
|
|
775
|
+
if (slices.length > 0)
|
|
776
|
+
tx.insert(epicSlices).values(slices.map((slice) => ({ ...slice, epicId: id, readAt, truncated }))).run();
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
catch (e) {
|
|
780
|
+
if (e instanceof AuthError)
|
|
781
|
+
throw e;
|
|
782
|
+
log.error('slices read failed, keeping the last rows', e);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
// Reads one epic's slices now, or once at the end of the window when one was read less than sliceWindowMs ago: a burst makes one read
|
|
786
|
+
// per window and the last change is seen; one slower than the window is followed by the next, never overlapped (readEpicSlices).
|
|
787
|
+
const sliceReadAt = new Map();
|
|
788
|
+
const sliceReadLater = new Map();
|
|
789
|
+
function readSlicesSoon(id, repo, issue) {
|
|
790
|
+
if (stopping || suspended || sliceReadLater.has(id))
|
|
791
|
+
return;
|
|
792
|
+
const read = async () => {
|
|
793
|
+
sliceReadLater.delete(id);
|
|
794
|
+
if (stopping || suspended)
|
|
795
|
+
return;
|
|
796
|
+
sliceReadAt.set(id, clock.now());
|
|
797
|
+
try {
|
|
798
|
+
await readEpicSlices(repo, id, issue);
|
|
799
|
+
}
|
|
800
|
+
catch (e) {
|
|
801
|
+
failed(e); // only a rejected profile gets here: it suspends the engine like in a poll
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
const wait = (sliceReadAt.get(id) ?? -sliceWindowMs) + sliceWindowMs - clock.now();
|
|
805
|
+
if (wait <= 0)
|
|
806
|
+
track(read());
|
|
807
|
+
else
|
|
808
|
+
sliceReadLater.set(id, clock.after(wait, read));
|
|
809
|
+
}
|
|
810
|
+
// The net: every minute, whatever the events said, the slices of the live epics (also a slice taken by hand on GitHub).
|
|
811
|
+
let cancelSliceTimer = null;
|
|
812
|
+
const sliceTimer = () => {
|
|
813
|
+
if (stopping)
|
|
814
|
+
return;
|
|
815
|
+
cancelSliceTimer = clock.after(sliceTimerMs, async () => {
|
|
816
|
+
const configured = repos.map((r) => r.name);
|
|
817
|
+
for (const { id, repo, issue } of db.select().from(epics).where(and(inArray(epics.status, liveStatuses), inArray(epics.repo, configured))).orderBy(asc(epics.id)).all())
|
|
818
|
+
readSlicesSoon(id, repo, issue);
|
|
819
|
+
sliceTimer();
|
|
820
|
+
});
|
|
821
|
+
};
|
|
822
|
+
const stopSliceReads = () => {
|
|
823
|
+
cancelSliceTimer?.();
|
|
824
|
+
for (const cancelRead of sliceReadLater.values())
|
|
825
|
+
cancelRead();
|
|
826
|
+
};
|
|
827
|
+
async function refreshSlices(repo) {
|
|
828
|
+
const stored = new Set(db.select({ id: epicSlices.epicId }).from(epicSlices).all().map((r) => r.id));
|
|
829
|
+
for (const { id, issue } of toRefresh(repo, (e) => stored.has(e.id)))
|
|
830
|
+
await readEpicSlices(repo, id, issue);
|
|
831
|
+
}
|
|
832
|
+
// The pull request block and review threads of each epic to refresh that has a PR, one query per epic and none for one without. A cache of GitHub
|
|
833
|
+
// like the slices: a failed read keeps the last block and rows; a PR that GitHub does not know leaves them as they were.
|
|
834
|
+
async function refreshPullRequests(repo) {
|
|
835
|
+
for (const { id, prNumber } of toRefresh(repo, (e) => e.pr !== null || e.prNumber === null)) {
|
|
836
|
+
if (prNumber === null)
|
|
837
|
+
continue;
|
|
838
|
+
try {
|
|
839
|
+
const read = await github.readPullRequest(repo, prNumber);
|
|
840
|
+
if (read === null)
|
|
841
|
+
continue;
|
|
842
|
+
const { pr, threads, truncated } = read;
|
|
843
|
+
const readAt = clock.now();
|
|
844
|
+
db.transaction((tx) => {
|
|
845
|
+
tx.update(epics).set({ pr }).where(eq(epics.id, id)).run();
|
|
846
|
+
tx.delete(epicReviewThreads).where(eq(epicReviewThreads.epicId, id)).run();
|
|
847
|
+
if (threads.length > 0)
|
|
848
|
+
tx.insert(epicReviewThreads).values(threads.map((thread, position) => ({ ...thread, epicId: id, position, readAt, truncated }))).run();
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
catch (e) {
|
|
852
|
+
if (e instanceof AuthError)
|
|
853
|
+
throw e;
|
|
854
|
+
log.error('pull request read failed, keeping the last block and threads', e);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// Returns the wait until the next poll.
|
|
859
|
+
async function poll() {
|
|
860
|
+
if (stopping)
|
|
861
|
+
return pollIntervalMs;
|
|
862
|
+
const read = new Set();
|
|
863
|
+
try {
|
|
864
|
+
// Fetch every repo before writing: a failure on any of them leaves the queue untouched.
|
|
865
|
+
const found = [];
|
|
866
|
+
await ensureLabels();
|
|
867
|
+
if (!recovered)
|
|
868
|
+
await recover();
|
|
869
|
+
for (const { name: repo } of repos) {
|
|
870
|
+
for (const { issue, title, labels } of await github.listReadyEpics(repo))
|
|
871
|
+
found.push({ repo, issue, title, labels });
|
|
872
|
+
await refreshProgress(repo);
|
|
873
|
+
await refreshSlices(repo);
|
|
874
|
+
await refreshPullRequests(repo);
|
|
875
|
+
checks.set(repo, { at: clock.now(), ok: true, error: null });
|
|
876
|
+
read.add(repo);
|
|
877
|
+
}
|
|
878
|
+
suspended = null; // listing worked: the orchestrator's profile is back, the cycle goes on
|
|
879
|
+
const queuedAt = clock.now();
|
|
880
|
+
db.transaction((tx) => {
|
|
881
|
+
for (const epic of found) {
|
|
882
|
+
tx.insert(epics).values({ ...epic, queuedAt }).onConflictDoUpdate({ target: [epics.repo, epics.issue], set: { title: epic.title, labels: epic.labels } }).run();
|
|
883
|
+
// Carrier never leaves `ready` on a stopped epic, so finding it on a `needs_human` one is a person saying "go on":
|
|
884
|
+
// it starts again from ship, which reuses the epic's worktree and PR.
|
|
885
|
+
tx.update(epics).set(restart(queuedAt)).where(and(eq(epics.repo, epic.repo), eq(epics.issue, epic.issue), eq(epics.status, 'needs_human'))).run();
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
await stopEnded();
|
|
889
|
+
await settlePending();
|
|
890
|
+
await retryPending();
|
|
891
|
+
await resumeChoices();
|
|
892
|
+
await reconcileLabels();
|
|
893
|
+
await fillSlots();
|
|
894
|
+
error = null;
|
|
895
|
+
return pollIntervalMs;
|
|
896
|
+
}
|
|
897
|
+
catch (e) {
|
|
898
|
+
log.error('poll failed', e);
|
|
899
|
+
failed(e);
|
|
900
|
+
// Only an expected failure gets here (`failed` rethrows the rest): the repos not read this cycle share it.
|
|
901
|
+
const reason = e instanceof AuthError ? `authentication error for profile ${e.profile}` : e.message;
|
|
902
|
+
for (const { name } of repos)
|
|
903
|
+
if (!read.has(name))
|
|
904
|
+
checks.set(name, { at: clock.now(), ok: false, error: reason });
|
|
905
|
+
return Math.max(pollIntervalMs, (e instanceof GitHubUnavailable && e.retryAfterMs) || 0);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// One poll at a time: a poll asked for by a person while the timer's is running is that one.
|
|
909
|
+
const runPoll = () => {
|
|
910
|
+
if (polling)
|
|
911
|
+
return polling;
|
|
912
|
+
polling = (async () => {
|
|
913
|
+
try {
|
|
914
|
+
schedule(await poll());
|
|
915
|
+
}
|
|
916
|
+
finally {
|
|
917
|
+
polling = null;
|
|
918
|
+
}
|
|
919
|
+
})();
|
|
920
|
+
return polling;
|
|
921
|
+
};
|
|
922
|
+
const schedule = (ms) => {
|
|
923
|
+
if (stopping)
|
|
924
|
+
return;
|
|
925
|
+
nextPollAt = clock.now() + ms;
|
|
926
|
+
cancel = clock.after(ms, runPoll);
|
|
927
|
+
};
|
|
928
|
+
return {
|
|
929
|
+
async start() {
|
|
930
|
+
stopRetention = startRetention(db, places.logs, clock, sessionDirs);
|
|
931
|
+
// GitHub down: retried every cycle until it works.
|
|
932
|
+
for (const step of [ensureLabels, recover]) {
|
|
933
|
+
try {
|
|
934
|
+
await step();
|
|
935
|
+
}
|
|
936
|
+
catch (err) {
|
|
937
|
+
log.error('startup step failed', err);
|
|
938
|
+
failed(err);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
schedule(pollIntervalMs);
|
|
942
|
+
sliceTimer();
|
|
943
|
+
},
|
|
944
|
+
stop() {
|
|
945
|
+
cancel?.();
|
|
946
|
+
for (const cancelChoice of choiceTimers.values())
|
|
947
|
+
cancelChoice();
|
|
948
|
+
choiceTimers.clear();
|
|
949
|
+
stopSliceReads();
|
|
950
|
+
stopRetention?.();
|
|
951
|
+
},
|
|
952
|
+
/**
|
|
953
|
+
* Ctrl+C: stops polling and reserving, then stops every active run without waiting for its stage.
|
|
954
|
+
* Their executions end `interrupted` (session ids already saved), the epics stay `running` and
|
|
955
|
+
* nothing is written to GitHub. Resolves once those states are in SQLite.
|
|
956
|
+
*/
|
|
957
|
+
async shutdown() {
|
|
958
|
+
stopping = true;
|
|
959
|
+
cancel?.();
|
|
960
|
+
for (const cancelChoice of choiceTimers.values())
|
|
961
|
+
cancelChoice();
|
|
962
|
+
choiceTimers.clear();
|
|
963
|
+
stopSliceReads();
|
|
964
|
+
stopRetention?.();
|
|
965
|
+
await Promise.all([...active.values()].map((run) => run.stop()));
|
|
966
|
+
while (inflight.size > 0)
|
|
967
|
+
await Promise.all([...inflight]);
|
|
968
|
+
},
|
|
969
|
+
/** Resolves when no execution is being followed any more. */
|
|
970
|
+
async idle() {
|
|
971
|
+
while (inflight.size > 0)
|
|
972
|
+
await Promise.all([...inflight]);
|
|
973
|
+
},
|
|
974
|
+
/** Polls now instead of waiting for the interval, then goes on from there. */
|
|
975
|
+
pollNow() {
|
|
976
|
+
if (stopping)
|
|
977
|
+
return Promise.resolve();
|
|
978
|
+
cancel?.();
|
|
979
|
+
return runPoll();
|
|
980
|
+
},
|
|
981
|
+
/**
|
|
982
|
+
* Puts `ready` on a stopped epic's issue, as a person does, and checks now; resolves once the epic is no longer in
|
|
983
|
+
* needs_human. It restarts on the option named for each role and on the one it has for a role left out, without waiting for a choice.
|
|
984
|
+
* Rejects with ResumeRefused: nothing was changed unless it is `did not restart`, which leaves the label for the next check.
|
|
985
|
+
*/
|
|
986
|
+
async resumeEpic(repo, issue, chosen = {}) {
|
|
987
|
+
const epic = db.select().from(epics).where(and(eq(epics.repo, repo), eq(epics.issue, issue))).get();
|
|
988
|
+
if (!epic)
|
|
989
|
+
throw new ResumeRefused('unknown epic');
|
|
990
|
+
if (epic.status !== 'needs_human')
|
|
991
|
+
throw new ResumeRefused('not stopped');
|
|
992
|
+
// The requeue freezes the epic's options again from the configuration the engine runs with, so that is what the names are checked against.
|
|
993
|
+
const repoConfig = repos.find((r) => r.name === repo);
|
|
994
|
+
const declared = (repoConfig?.providers ?? repoConfig?.options) ?? {};
|
|
995
|
+
if ([chosen.author, chosen.reviewer].some((name) => name !== undefined && !Object.hasOwn(declared, name)))
|
|
996
|
+
throw new ResumeRefused('unknown option');
|
|
997
|
+
// A role left out keeps the option it has: the epic is not asked again.
|
|
998
|
+
const author = chosen.author ?? epic.authorOption;
|
|
999
|
+
const reviewer = chosen.reviewer ?? epic.reviewerOption;
|
|
1000
|
+
restarts.set(epic.id, { ...(author && { author }), ...(reviewer && { reviewer }) });
|
|
1001
|
+
try {
|
|
1002
|
+
await github.setLabels(repo, issue, { add: [readyLabel], remove: [] });
|
|
1003
|
+
}
|
|
1004
|
+
catch (e) {
|
|
1005
|
+
restarts.delete(epic.id);
|
|
1006
|
+
if (e instanceof GitHubUnavailable || e instanceof AuthError)
|
|
1007
|
+
throw new ResumeRefused('github unavailable');
|
|
1008
|
+
throw e;
|
|
1009
|
+
}
|
|
1010
|
+
// Not awaited: the wait below has the deadline. A poll already running is the one that answers, but it may have listed
|
|
1011
|
+
// before the label went on: once it settles with the epic still stopped, one more check is asked for.
|
|
1012
|
+
const check = () => this.pollNow().catch((e) => log.error('check for a resumed epic failed', e));
|
|
1013
|
+
let settled = false;
|
|
1014
|
+
void check().then(() => (settled = true));
|
|
1015
|
+
let again = false;
|
|
1016
|
+
const deadline = clock.now() + resumeWaitMs;
|
|
1017
|
+
while (fresh(epic.id).status === 'needs_human') {
|
|
1018
|
+
if (clock.now() >= deadline) {
|
|
1019
|
+
restarts.delete(epic.id);
|
|
1020
|
+
throw new ResumeRefused('did not restart');
|
|
1021
|
+
}
|
|
1022
|
+
if (settled && !again) {
|
|
1023
|
+
again = true;
|
|
1024
|
+
void check();
|
|
1025
|
+
}
|
|
1026
|
+
await new Promise((resolve) => clock.after(resumeTickMs, async () => resolve()));
|
|
1027
|
+
}
|
|
1028
|
+
},
|
|
1029
|
+
/**
|
|
1030
|
+
* Ends the wait of an epic that is waiting for its choice: it starts on the option named for each role and on the saved one for a role left out,
|
|
1031
|
+
* and this resolves once the author is launched. Rejects with ResumeRefused, changing nothing: an unknown epic, one that is not waiting, an option not declared.
|
|
1032
|
+
*/
|
|
1033
|
+
async chooseOptions(repo, issue, chosen) {
|
|
1034
|
+
const epic = db.select().from(epics).where(and(eq(epics.repo, repo), eq(epics.issue, issue))).get();
|
|
1035
|
+
if (!epic)
|
|
1036
|
+
throw new ResumeRefused('unknown epic');
|
|
1037
|
+
if (epic.status !== 'running' || epic.choiceDeadline === null)
|
|
1038
|
+
throw new ResumeRefused('not waiting');
|
|
1039
|
+
const declared = (epic.config?.providers ?? epic.config?.options) ?? {};
|
|
1040
|
+
if ([chosen.author, chosen.reviewer].some((name) => name !== undefined && !Object.hasOwn(declared, name)))
|
|
1041
|
+
throw new ResumeRefused('unknown option');
|
|
1042
|
+
if (!(await startChosen(epic.id, chosen)))
|
|
1043
|
+
throw new ResumeRefused('not waiting');
|
|
1044
|
+
},
|
|
1045
|
+
/** Stops (or resumes) taking epics from the queue; running epics are not touched. Resuming fills the free slots at once. */
|
|
1046
|
+
setPaused(value) {
|
|
1047
|
+
paused = value;
|
|
1048
|
+
return value ? Promise.resolve() : this.pollNow();
|
|
1049
|
+
},
|
|
1050
|
+
get paused() {
|
|
1051
|
+
return paused;
|
|
1052
|
+
},
|
|
1053
|
+
/** When the next poll is due (ms since epoch); null before the first one is scheduled. */
|
|
1054
|
+
get nextPollAt() {
|
|
1055
|
+
return nextPollAt;
|
|
1056
|
+
},
|
|
1057
|
+
/** Last GitHub failure, null once a poll succeeds. */
|
|
1058
|
+
get error() {
|
|
1059
|
+
return error;
|
|
1060
|
+
},
|
|
1061
|
+
/** Slices closed out of the total on the last good read; null until the first one or when GitHub sends none. */
|
|
1062
|
+
slices: (repo, issue) => progress.get(repo)?.get(issue) ?? null,
|
|
1063
|
+
/** The last poll of a repo; undefined until the first one after a start. */
|
|
1064
|
+
lastCheck: (repo) => checks.get(repo),
|
|
1065
|
+
/** Queued epics, oldest first. */
|
|
1066
|
+
queue: () => db.select().from(epics).where(eq(epics.status, 'queued')).orderBy(asc(epics.id)).all(),
|
|
1067
|
+
};
|
|
1068
|
+
}
|