beflow 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 +121 -0
- package/config.example.json +68 -0
- package/config.schema.json +413 -0
- package/package.json +72 -0
- package/src/agent/acpx.ts +197 -0
- package/src/agent/driver.ts +38 -0
- package/src/agent/events.ts +228 -0
- package/src/agent/issuefence.ts +42 -0
- package/src/agent/report.ts +44 -0
- package/src/cli.ts +910 -0
- package/src/config/load.ts +45 -0
- package/src/config/persist.ts +58 -0
- package/src/config/schema.ts +181 -0
- package/src/config/store.ts +119 -0
- package/src/core/accept.ts +25 -0
- package/src/core/continuation.ts +57 -0
- package/src/core/deadletter.ts +55 -0
- package/src/core/decision.ts +8 -0
- package/src/core/doctor.ts +223 -0
- package/src/core/drift.ts +59 -0
- package/src/core/gc.ts +223 -0
- package/src/core/inputquality.ts +30 -0
- package/src/core/issuetemplate.ts +175 -0
- package/src/core/mcp.ts +191 -0
- package/src/core/newissue.ts +343 -0
- package/src/core/notify.ts +151 -0
- package/src/core/prompts.ts +165 -0
- package/src/core/qualitygate.ts +70 -0
- package/src/core/queue.ts +40 -0
- package/src/core/review.ts +266 -0
- package/src/core/run.ts +1075 -0
- package/src/core/runstore.ts +144 -0
- package/src/core/runsview.ts +111 -0
- package/src/core/setup.ts +203 -0
- package/src/core/sla.ts +39 -0
- package/src/core/template.ts +65 -0
- package/src/core/watch.ts +825 -0
- package/src/core/worktree.ts +74 -0
- package/src/core/writeback.ts +88 -0
- package/src/index.ts +154 -0
- package/src/model/types.ts +35 -0
- package/src/prompts/defaults/continuation.md +9 -0
- package/src/prompts/defaults/implement.md +13 -0
- package/src/prompts/defaults/issue-enrich.md +30 -0
- package/src/prompts/defaults/issues/bug.md +35 -0
- package/src/prompts/defaults/issues/feature.md +24 -0
- package/src/prompts/defaults/issues/generic.md +16 -0
- package/src/prompts/defaults/issues/spike.md +24 -0
- package/src/prompts/defaults/report.md +20 -0
- package/src/prompts/defaults/review.md +34 -0
- package/src/prompts/defaults/spec.md +11 -0
- package/src/prompts/defaults/task.md +6 -0
- package/src/prompts/defaults/triage.md +11 -0
- package/src/prompts/text-modules.d.ts +4 -0
- package/src/resolve/jobkind.ts +11 -0
- package/src/resolve/metadata.ts +103 -0
- package/src/resolve/precedence.ts +104 -0
- package/src/trackers/factory.ts +17 -0
- package/src/trackers/linear/adapter.ts +416 -0
- package/src/trackers/linear/client.ts +264 -0
- package/src/trackers/linear/map.ts +113 -0
- package/src/trackers/linear/types.ts +44 -0
- package/src/trackers/marker.ts +20 -0
- package/src/trackers/plane/adapter.ts +754 -0
- package/src/trackers/plane/client.ts +302 -0
- package/src/trackers/plane/map.ts +168 -0
- package/src/trackers/plane/types.ts +134 -0
- package/src/trackers/tracker.ts +135 -0
package/src/core/run.ts
ADDED
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { cancel, intro, isCancel, outro, select, text } from "@clack/prompts";
|
|
4
|
+
import * as bun from "bun";
|
|
5
|
+
|
|
6
|
+
import { resolveAcpCommand, resolveAcpxCommand } from "../agent/acpx.ts";
|
|
7
|
+
import type { AgentDriver, AgentRunResult, RunOptions } from "../agent/driver.ts";
|
|
8
|
+
import type { Report, ReportStatus } from "../agent/report.ts";
|
|
9
|
+
import type { Config, Project, Registry } from "../config/schema.ts";
|
|
10
|
+
import type { Issue, JobKind, Resolved } from "../model/types.ts";
|
|
11
|
+
import { resolve } from "../resolve/precedence.ts";
|
|
12
|
+
import type { Comment, Tracker } from "../trackers/tracker.ts";
|
|
13
|
+
import { renderContinuation } from "./continuation.ts";
|
|
14
|
+
import { DECISION_HOLD_MESSAGE, isDecisionHeld } from "./decision.ts";
|
|
15
|
+
import { isThinIssue, resolveMinBodyChars, THIN_ISSUE_MESSAGE } from "./inputquality.ts";
|
|
16
|
+
import { injectAcpxMcp, nodeMcpFs } from "./mcp.ts";
|
|
17
|
+
import type { McpFs, McpServer } from "./mcp.ts";
|
|
18
|
+
import { escalationDetail, notifyEscalation } from "./notify.ts";
|
|
19
|
+
import type { Notifier } from "./notify.ts";
|
|
20
|
+
import type { PromptSet } from "./prompts.ts";
|
|
21
|
+
import { renderContract, renderLinkedContext, renderTask } from "./prompts.ts";
|
|
22
|
+
import { defaultGateExec, resolveQualityGate, runQualityGate } from "./qualitygate.ts";
|
|
23
|
+
import type { GateExec } from "./qualitygate.ts";
|
|
24
|
+
import { deleteRecord, loadRecord, resolveRunsDir, saveRecord, systemClock } from "./runstore.ts";
|
|
25
|
+
import type { Clock, RunRecord, RunStoreFs } from "./runstore.ts";
|
|
26
|
+
import { formatTelemetryLine, resolveTelemetryInComment } from "./runsview.ts";
|
|
27
|
+
import { createWorktree, removeWorktree, resolveWorktreeDir, sanitizeKey } from "./worktree.ts";
|
|
28
|
+
import type { Exec } from "./worktree.ts";
|
|
29
|
+
import { applyReport, buildCommentBody, defaultDoneState } from "./writeback.ts";
|
|
30
|
+
import type { WritebackResult } from "./writeback.ts";
|
|
31
|
+
|
|
32
|
+
const IN_PROGRESS_STATE = "In Progress";
|
|
33
|
+
const DEFAULT_MANUAL_MOVE_POLL_MS = 15000;
|
|
34
|
+
const SECONDS_PER_MINUTE = 60;
|
|
35
|
+
|
|
36
|
+
export type Logger = (msg: string) => void;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A human has pulled an issue out of beflow's hands when its CURRENT state group
|
|
40
|
+
* is not `started`. beflow's own run states (In Progress / Needs Input / In
|
|
41
|
+
* Review) are all `started`, so in the normal flow this is false — it only turns
|
|
42
|
+
* true when a human drags the card to Backlog/Todo/Done/Cancelled mid-run.
|
|
43
|
+
*/
|
|
44
|
+
export function isPulledByHuman(issue: Issue): boolean {
|
|
45
|
+
return issue.state.group !== "started";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function realSleep(ms: number): Promise<void> {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
setTimeout(resolve, ms);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ResolvedRun {
|
|
55
|
+
issue: Issue;
|
|
56
|
+
project: Project;
|
|
57
|
+
resolved: Resolved;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function projectKeyOf(issueKey: string): string {
|
|
61
|
+
const dash = issueKey.lastIndexOf("-");
|
|
62
|
+
if (dash === -1) {
|
|
63
|
+
throw new Error(`beflow: malformed issue key "${issueKey}"`);
|
|
64
|
+
}
|
|
65
|
+
return issueKey.slice(0, dash);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function resolveRun(
|
|
69
|
+
key: string,
|
|
70
|
+
cli: Partial<Resolved>,
|
|
71
|
+
config: Config,
|
|
72
|
+
registry: Registry,
|
|
73
|
+
tracker: Tracker,
|
|
74
|
+
): Promise<ResolvedRun> {
|
|
75
|
+
const issue = await tracker.getIssue(key);
|
|
76
|
+
const projectKey = projectKeyOf(key);
|
|
77
|
+
const project = registry.projects[projectKey];
|
|
78
|
+
if (project === undefined) {
|
|
79
|
+
const known = Object.keys(registry.projects).join(", ");
|
|
80
|
+
throw new Error(`beflow: unknown project key "${projectKey}" (known: ${known})`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const resolved = resolve({
|
|
84
|
+
cli,
|
|
85
|
+
global: config.defaults,
|
|
86
|
+
issue: {
|
|
87
|
+
areas: issue.areas,
|
|
88
|
+
state: { group: issue.state.group },
|
|
89
|
+
type: issue.type,
|
|
90
|
+
},
|
|
91
|
+
meta: tracker.readMetadata(issue),
|
|
92
|
+
project,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return { issue, project, resolved };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ManualMovePoller {
|
|
99
|
+
stop: () => void;
|
|
100
|
+
/** Resolves when the poll loop has fully settled. */
|
|
101
|
+
settled: Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* While an autonomous run is live, poll the board on an interval; if a human pulls
|
|
106
|
+
* the issue out of the started group, cancel the agent cooperatively so the run
|
|
107
|
+
* ends early instead of burning to completion. The end-of-run re-read in runIssue
|
|
108
|
+
* is what authoritatively decides to skip writeback — this only shortens the run.
|
|
109
|
+
*/
|
|
110
|
+
function startManualMovePoller(args: {
|
|
111
|
+
key: string;
|
|
112
|
+
cwd: string;
|
|
113
|
+
acpCommand: string;
|
|
114
|
+
pollMs: number;
|
|
115
|
+
sleep: (ms: number) => Promise<void>;
|
|
116
|
+
tracker: Tracker;
|
|
117
|
+
driver: AgentDriver;
|
|
118
|
+
log: Logger;
|
|
119
|
+
}): ManualMovePoller {
|
|
120
|
+
// Mutable flags live on an object written by both `stop()` and the loop. They are
|
|
121
|
+
// Read through `isStopped()` so the boolean isn't narrowed to its initial literal
|
|
122
|
+
// Across the `await` points (where `stop()` may have fired concurrently).
|
|
123
|
+
const state = { stopped: false };
|
|
124
|
+
function isStopped(): boolean {
|
|
125
|
+
return state.stopped;
|
|
126
|
+
}
|
|
127
|
+
const loop = (async (): Promise<void> => {
|
|
128
|
+
while (!isStopped()) {
|
|
129
|
+
await args.sleep(args.pollMs);
|
|
130
|
+
if (isStopped()) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
const live = await args.tracker.getIssue(args.key);
|
|
134
|
+
if (isStopped()) {
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
if (isPulledByHuman(live)) {
|
|
138
|
+
args.log(`beflow: ${args.key} — manual move detected (now ${live.state.name}); cancelling the agent`);
|
|
139
|
+
await args.driver.cancel(args.key, args.cwd, args.acpCommand);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
})();
|
|
144
|
+
return {
|
|
145
|
+
settled: loop,
|
|
146
|
+
stop: () => {
|
|
147
|
+
state.stopped = true;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function moveToInProgress(tracker: Tracker, issue: Issue): Promise<void> {
|
|
153
|
+
if (issue.state.group === "started") {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
await tracker.updateState(issue, IN_PROGRESS_STATE);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Re-read the live issue at the end of a run and, if a human pulled the card out
|
|
161
|
+
* of the started group, yield STATE authority to them: skip writeback, preserve
|
|
162
|
+
* the agent's report as a comment, run any extra cleanup, and delete the record.
|
|
163
|
+
* Returns true when it yielded (the caller must stop and return early), false when
|
|
164
|
+
* the issue is still ours and the normal writeback path should proceed.
|
|
165
|
+
*/
|
|
166
|
+
async function yieldToManualMove(args: {
|
|
167
|
+
tracker: Tracker;
|
|
168
|
+
key: string;
|
|
169
|
+
issue: Issue;
|
|
170
|
+
report: Report | null;
|
|
171
|
+
runsDir: string;
|
|
172
|
+
runsFs?: RunStoreFs;
|
|
173
|
+
log: Logger;
|
|
174
|
+
cleanup?: () => Promise<void>;
|
|
175
|
+
}): Promise<boolean> {
|
|
176
|
+
const fresh = await args.tracker.getIssue(args.key);
|
|
177
|
+
if (!isPulledByHuman(fresh)) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (args.report !== null) {
|
|
181
|
+
await args.tracker.comment(args.issue, buildCommentBody(args.report));
|
|
182
|
+
}
|
|
183
|
+
if (args.cleanup !== undefined) {
|
|
184
|
+
await args.cleanup();
|
|
185
|
+
}
|
|
186
|
+
deleteRecord(args.runsDir, args.key, args.runsFs);
|
|
187
|
+
args.log(`beflow: ${args.key} — yielded to manual move (now ${fresh.state.name}); writeback skipped`);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Fetch + render the linked context (parent epic + attachments) for the agent
|
|
192
|
+
// Task. Fully degrade-safe: disabled or any fetch failure yields "" so a run
|
|
193
|
+
// Never breaks on missing context.
|
|
194
|
+
async function gatherLinkedContext(tracker: Tracker, issue: Issue, enabled: boolean, log: Logger): Promise<string> {
|
|
195
|
+
if (!enabled) {
|
|
196
|
+
return "";
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
return renderLinkedContext(await tracker.issueContext(issue));
|
|
200
|
+
} catch (err) {
|
|
201
|
+
log(
|
|
202
|
+
`beflow: ${issue.key} — linked-context fetch failed (continuing without it): ${err instanceof Error ? err.message : String(err)}`,
|
|
203
|
+
);
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface RunIssueDeps {
|
|
209
|
+
tracker: Tracker;
|
|
210
|
+
driver: AgentDriver;
|
|
211
|
+
config: Config;
|
|
212
|
+
registry: Registry;
|
|
213
|
+
prompts: PromptSet;
|
|
214
|
+
git?: Exec;
|
|
215
|
+
log?: Logger;
|
|
216
|
+
runsFs?: RunStoreFs;
|
|
217
|
+
clock?: Clock;
|
|
218
|
+
fresh?: boolean;
|
|
219
|
+
pathExists?: (p: string) => boolean;
|
|
220
|
+
preResolved?: ResolvedRun;
|
|
221
|
+
continuation?: string;
|
|
222
|
+
sleep?: (ms: number) => Promise<void>;
|
|
223
|
+
manualMovePollMs?: number;
|
|
224
|
+
notify?: Notifier;
|
|
225
|
+
mcpServers?: McpServer[];
|
|
226
|
+
mcpFs?: McpFs;
|
|
227
|
+
gateExec?: GateExec;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const RESUME_STATUSES: ReadonlySet<RunRecord["status"]> = new Set([
|
|
231
|
+
"in_progress",
|
|
232
|
+
"needs_input",
|
|
233
|
+
"blocked",
|
|
234
|
+
"done",
|
|
235
|
+
"failed",
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
const IN_REVIEW_INSTRUCTION =
|
|
239
|
+
"This work item is now **In Review**. To request changes: add the `changes-requested` label **and** leave a comment describing what to change. Adding the label without a comment will not start a rework — beflow will ask you for the description.";
|
|
240
|
+
|
|
241
|
+
// Appended only in runOpen: tells the agent to exit its interactive session so beflow can resume.
|
|
242
|
+
export const OPEN_SESSION_TRAILER =
|
|
243
|
+
"\n\n---\n\nYou are running inside beflow's supervised `--open` mode, in the user's own interactive agent session — they are present and supervising. If anything about the task above is unclear, just ask them directly. When you have finished, end your turn with a brief status summary, then tell the user to close this agent session (for example `/exit` or Ctrl+C) so beflow can resume and record the outcome. Do not stop silently.";
|
|
244
|
+
|
|
245
|
+
async function postInReviewInstructionOnce(tracker: Tracker, issue: Issue, log: Logger): Promise<void> {
|
|
246
|
+
const sentinel = "To request changes: add the `changes-requested` label";
|
|
247
|
+
const comments = await tracker.listComments(issue);
|
|
248
|
+
if (comments.some((c) => c.isBot && c.body.includes(sentinel))) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
await tracker.comment(issue, IN_REVIEW_INSTRUCTION);
|
|
252
|
+
log(`beflow: ${issue.key} → In Review; posted change-request instructions`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface RunResult {
|
|
256
|
+
issue: Issue;
|
|
257
|
+
resolved: Resolved;
|
|
258
|
+
cwd: string;
|
|
259
|
+
result: AgentRunResult;
|
|
260
|
+
applied?: WritebackResult;
|
|
261
|
+
parked?: "decision" | "thin";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function runIssue(key: string, cli: Partial<Resolved>, deps: RunIssueDeps): Promise<RunResult> {
|
|
265
|
+
const log =
|
|
266
|
+
deps.log ??
|
|
267
|
+
((): void => {
|
|
268
|
+
/* no-op: logging disabled */
|
|
269
|
+
});
|
|
270
|
+
const clock = deps.clock ?? systemClock;
|
|
271
|
+
const pathExists = deps.pathExists ?? existsSync;
|
|
272
|
+
const { issue, resolved } =
|
|
273
|
+
deps.preResolved ?? (await resolveRun(key, cli, deps.config, deps.registry, deps.tracker));
|
|
274
|
+
|
|
275
|
+
const runsDir = resolveRunsDir(deps.config.runs?.dir);
|
|
276
|
+
const baseDir = resolveWorktreeDir(deps.config.worktrees?.dir);
|
|
277
|
+
const useWorktree = resolved.runMode === "autonomous" && deps.git !== undefined;
|
|
278
|
+
|
|
279
|
+
let prior = loadRecord(runsDir, key, deps.runsFs);
|
|
280
|
+
if (deps.fresh === true) {
|
|
281
|
+
if (prior !== null && useWorktree && deps.git !== undefined) {
|
|
282
|
+
try {
|
|
283
|
+
await removeWorktree(resolved.repoPath, prior.cwd, deps.git);
|
|
284
|
+
log(`beflow: removed worktree at ${prior.cwd} (--fresh)`);
|
|
285
|
+
} catch {
|
|
286
|
+
// Best-effort: a stale or already-removed worktree must not block a fresh run
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
deleteRecord(runsDir, key, deps.runsFs);
|
|
290
|
+
prior = null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// INPUT-QUALITY GATE (opt-in): on a FRESH autonomous dispatch only — not a resume,
|
|
294
|
+
// Not a continuation (rework/answered) — a too-thin description is parked to Needs
|
|
295
|
+
// Input with a comment, BEFORE any worktree is created or the board is claimed, so
|
|
296
|
+
// We never burn an agent run on an issue an agent can't act on safely.
|
|
297
|
+
const hasResumablePrior = prior !== null && RESUME_STATUSES.has(prior.status) && pathExists(prior.cwd);
|
|
298
|
+
const isFreshDispatch = deps.continuation === undefined && !hasResumablePrior;
|
|
299
|
+
if (isFreshDispatch) {
|
|
300
|
+
// DECISION GATE (always on; the per-issue label IS the opt-in): an explicit
|
|
301
|
+
// `needs-decision` label outranks the thin heuristic, so it is checked first.
|
|
302
|
+
// The issue is parked to Needs Input and escalated, and a hold record is written
|
|
303
|
+
// So watch's release pass can detect (and ONLY) this hold; the human makes the
|
|
304
|
+
// Call by removing the label, which releases the issue back to Todo. Like the
|
|
305
|
+
// Thin gate, this lives in runIssue (autonomous) only — runSupervised/runOpen
|
|
306
|
+
// Have a human present who can make the call without parking.
|
|
307
|
+
if (isDecisionHeld(issue.labels)) {
|
|
308
|
+
const report: Report = { status: "needs_input", summary: DECISION_HOLD_MESSAGE };
|
|
309
|
+
const applied = await applyReport(deps.tracker, issue, report, resolved.jobKind);
|
|
310
|
+
saveRecord(
|
|
311
|
+
runsDir,
|
|
312
|
+
{
|
|
313
|
+
agent: resolved.agent,
|
|
314
|
+
cwd: resolved.repoPath,
|
|
315
|
+
heldReason: "decision",
|
|
316
|
+
jobKind: resolved.jobKind,
|
|
317
|
+
key,
|
|
318
|
+
repoPath: resolved.repoPath,
|
|
319
|
+
runMode: resolved.runMode,
|
|
320
|
+
sessionName: key,
|
|
321
|
+
status: "needs_input",
|
|
322
|
+
tracker: deps.config.tracker,
|
|
323
|
+
updatedAt: clock(),
|
|
324
|
+
},
|
|
325
|
+
deps.runsFs,
|
|
326
|
+
);
|
|
327
|
+
await notifyEscalation(deps.notify, issue, "needs_input", "decision-gate: awaiting human decision");
|
|
328
|
+
log(`beflow: ${key} held for human decision (needs-decision label) → Needs Input`);
|
|
329
|
+
return {
|
|
330
|
+
applied,
|
|
331
|
+
cwd: resolved.repoPath,
|
|
332
|
+
issue,
|
|
333
|
+
parked: "decision",
|
|
334
|
+
resolved,
|
|
335
|
+
result: { exitCode: 0, raw: [], report, stream: { assistantText: "", toolCalls: [] }, timedOut: false },
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const minBodyChars = resolveMinBodyChars(deps.config, deps.registry, projectKeyOf(key));
|
|
340
|
+
if (isThinIssue(issue.body, minBodyChars)) {
|
|
341
|
+
const report: Report = { status: "needs_input", summary: THIN_ISSUE_MESSAGE };
|
|
342
|
+
const applied = await applyReport(deps.tracker, issue, report, resolved.jobKind);
|
|
343
|
+
await notifyEscalation(deps.notify, issue, "needs_input", "input-quality: thin description");
|
|
344
|
+
log(`beflow: ${key} parked: thin description (< ${String(minBodyChars)} chars) → Needs Input`);
|
|
345
|
+
return {
|
|
346
|
+
applied,
|
|
347
|
+
cwd: resolved.repoPath,
|
|
348
|
+
issue,
|
|
349
|
+
parked: "thin",
|
|
350
|
+
resolved,
|
|
351
|
+
result: {
|
|
352
|
+
exitCode: 0,
|
|
353
|
+
raw: [],
|
|
354
|
+
report,
|
|
355
|
+
stream: { assistantText: "", toolCalls: [] },
|
|
356
|
+
timedOut: false,
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let cwd = resolved.repoPath;
|
|
363
|
+
let branch: string | undefined;
|
|
364
|
+
let isResume = false;
|
|
365
|
+
let worktreeCreated = false;
|
|
366
|
+
|
|
367
|
+
if (useWorktree) {
|
|
368
|
+
if (hasResumablePrior && prior !== null) {
|
|
369
|
+
({ cwd } = prior);
|
|
370
|
+
({ branch } = prior);
|
|
371
|
+
isResume = true;
|
|
372
|
+
log(`beflow: resuming ${key} in existing worktree at ${cwd}`);
|
|
373
|
+
} else {
|
|
374
|
+
const git = deps.git;
|
|
375
|
+
if (git === undefined) {
|
|
376
|
+
throw new Error(`beflow: ${key} requires git for worktree creation but none was provided`);
|
|
377
|
+
}
|
|
378
|
+
cwd = await createWorktree(resolved.repoPath, key, git, baseDir);
|
|
379
|
+
branch = `beflow/${sanitizeKey(key)}`;
|
|
380
|
+
worktreeCreated = true;
|
|
381
|
+
log(`beflow: created worktree at ${cwd}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (isResume && prior !== null && prior.tracker !== undefined && prior.tracker !== deps.config.tracker) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`beflow: ${key} was started under tracker "${prior.tracker}"; config now uses "${deps.config.tracker}". Finish it under the original tracker, or re-run with --fresh to restart.`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const effectiveAgent = isResume && prior !== null ? prior.agent : resolved.agent;
|
|
392
|
+
const effectiveJobKind = isResume && prior !== null ? prior.jobKind : resolved.jobKind;
|
|
393
|
+
const effectiveRunMode = isResume && prior !== null ? prior.runMode : resolved.runMode;
|
|
394
|
+
const effectiveRepoPath =
|
|
395
|
+
isResume && prior !== null && prior.repoPath !== undefined ? prior.repoPath : resolved.repoPath;
|
|
396
|
+
|
|
397
|
+
// attempts counts CONSECUTIVE crash resumes only. A fresh dispatch resets to 0,
|
|
398
|
+
// and a human-driven re-dispatch (rework/answered) passes a continuation, so it
|
|
399
|
+
// also resets to 0 — only an unattended crash-resume increments the streak.
|
|
400
|
+
const isCrashResume = isResume && deps.continuation === undefined;
|
|
401
|
+
const attempts = isCrashResume ? (prior?.attempts ?? 0) + 1 : 0;
|
|
402
|
+
|
|
403
|
+
const record: RunRecord = {
|
|
404
|
+
key,
|
|
405
|
+
agent: effectiveAgent,
|
|
406
|
+
attempts,
|
|
407
|
+
cwd,
|
|
408
|
+
...(branch !== undefined ? { branch } : {}),
|
|
409
|
+
sessionName: key,
|
|
410
|
+
jobKind: effectiveJobKind,
|
|
411
|
+
runMode: effectiveRunMode,
|
|
412
|
+
status: "in_progress",
|
|
413
|
+
updatedAt: clock(),
|
|
414
|
+
tracker: prior?.tracker ?? deps.config.tracker,
|
|
415
|
+
repoPath: effectiveRepoPath,
|
|
416
|
+
};
|
|
417
|
+
// RECORD-FIRST: the record is the claim. Write it before touching the board so a
|
|
418
|
+
// crash in the gap can't leave an In-Progress issue with no record (orphan window).
|
|
419
|
+
saveRecord(runsDir, record, deps.runsFs);
|
|
420
|
+
|
|
421
|
+
await moveToInProgress(deps.tracker, issue);
|
|
422
|
+
if (deps.config.defaults.assignee !== undefined) {
|
|
423
|
+
await deps.tracker.assign(issue, deps.config.defaults.assignee);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const acpCommand = resolveAcpCommand(effectiveAgent, deps.config.agents[effectiveAgent]);
|
|
427
|
+
|
|
428
|
+
// Inject the translated `.mcp.json` as a managed `.acpxrc.json` into the agent
|
|
429
|
+
// Cwd so acpx forwards the servers to ACP `session/new`. The cleanup restores
|
|
430
|
+
// The cwd in the finally that wraps the whole agent run (even on throw/timeout).
|
|
431
|
+
const mcpCleanup =
|
|
432
|
+
deps.mcpServers !== undefined && deps.mcpServers.length > 0
|
|
433
|
+
? injectAcpxMcp(cwd, deps.mcpServers, deps.mcpFs ?? nodeMcpFs)
|
|
434
|
+
: undefined;
|
|
435
|
+
|
|
436
|
+
const baseTask =
|
|
437
|
+
renderTask(deps.prompts, issue, resolved.repo) +
|
|
438
|
+
(await gatherLinkedContext(deps.tracker, issue, deps.config.defaults.linkedContext !== false, log));
|
|
439
|
+
const task =
|
|
440
|
+
deps.continuation !== undefined
|
|
441
|
+
? `${deps.continuation}\n\n${baseTask}`
|
|
442
|
+
: isResume
|
|
443
|
+
? `Resuming work item ${key}; you have prior context in this session — continue from where you left off and finish, then emit the report block.\n\n${baseTask}`
|
|
444
|
+
: baseTask;
|
|
445
|
+
|
|
446
|
+
const sleep = deps.sleep ?? realSleep;
|
|
447
|
+
const pollMs = deps.manualMovePollMs ?? DEFAULT_MANUAL_MOVE_POLL_MS;
|
|
448
|
+
const poller =
|
|
449
|
+
deps.config.defaults.onManualMove === "abort"
|
|
450
|
+
? startManualMovePoller({
|
|
451
|
+
acpCommand,
|
|
452
|
+
cwd,
|
|
453
|
+
driver: deps.driver,
|
|
454
|
+
key,
|
|
455
|
+
log,
|
|
456
|
+
pollMs,
|
|
457
|
+
sleep,
|
|
458
|
+
tracker: deps.tracker,
|
|
459
|
+
})
|
|
460
|
+
: undefined;
|
|
461
|
+
|
|
462
|
+
const maxRunMinutes = deps.registry.projects[projectKeyOf(key)]?.limits?.maxRunMinutes ?? 0;
|
|
463
|
+
|
|
464
|
+
// The same persistent ACP session is reused for the initial dispatch and for the
|
|
465
|
+
// Quality-gate auto-rework re-prompt below; only the `task` text differs.
|
|
466
|
+
function buildRunOptions(runTask: string): RunOptions {
|
|
467
|
+
return {
|
|
468
|
+
acpCommand,
|
|
469
|
+
contract: renderContract(deps.prompts, effectiveJobKind, issue, resolved.repo),
|
|
470
|
+
cwd,
|
|
471
|
+
nonInteractive: "fail",
|
|
472
|
+
runMode: "autonomous",
|
|
473
|
+
sessionKey: key,
|
|
474
|
+
task: runTask,
|
|
475
|
+
...(maxRunMinutes > 0 ? { timeoutSeconds: maxRunMinutes * SECONDS_PER_MINUTE } : {}),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let result: AgentRunResult;
|
|
480
|
+
try {
|
|
481
|
+
await deps.driver.ensureSession(key, cwd, acpCommand);
|
|
482
|
+
try {
|
|
483
|
+
result = await deps.driver.run(buildRunOptions(task), (evt) => {
|
|
484
|
+
log(`acpx: ${JSON.stringify(evt)}`);
|
|
485
|
+
});
|
|
486
|
+
} finally {
|
|
487
|
+
if (poller !== undefined) {
|
|
488
|
+
poller.stop();
|
|
489
|
+
await poller.settled;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
} finally {
|
|
493
|
+
// Restore the cwd's `.acpxrc.json` regardless of how the run ended (success,
|
|
494
|
+
// Throw, timeout, or cooperative cancel). For a throwaway worktree this is
|
|
495
|
+
// Harmless; for an in-place repo (rare on --auto) it keeps it clean.
|
|
496
|
+
mcpCleanup?.();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// HARD TIMEOUT: the driver killed a hung run past its wall-clock deadline and the
|
|
500
|
+
// Agent never emitted a report. Synthesize a `failed` report so the rest of the
|
|
501
|
+
// Function routes it through the SAME writeback path (yield check → applyReport →
|
|
502
|
+
// Terminal save → escalation) — a real failed report and a timeout park identically.
|
|
503
|
+
// An agent that DID emit a report before the kill keeps its own report (honored).
|
|
504
|
+
if (result.timedOut && result.report === null) {
|
|
505
|
+
log(`beflow: ${key} timed out after ${String(maxRunMinutes)} minutes; stopped and parking as failed`);
|
|
506
|
+
result = {
|
|
507
|
+
...result,
|
|
508
|
+
report: {
|
|
509
|
+
status: "failed",
|
|
510
|
+
summary: `Agent run timed out after ${String(maxRunMinutes)} minutes and was stopped automatically.`,
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// YIELD: the human is authoritative for STATE. If the card was moved out of the
|
|
516
|
+
// Started group while the agent worked, skip writeback so beflow doesn't fight
|
|
517
|
+
// The human — but preserve the agent's work as a comment and clean up.
|
|
518
|
+
const git = deps.git;
|
|
519
|
+
const yielded = await yieldToManualMove({
|
|
520
|
+
cleanup:
|
|
521
|
+
(worktreeCreated || isResume) && git !== undefined
|
|
522
|
+
? async (): Promise<void> => {
|
|
523
|
+
await removeWorktree(effectiveRepoPath, cwd, git);
|
|
524
|
+
}
|
|
525
|
+
: undefined,
|
|
526
|
+
issue,
|
|
527
|
+
key,
|
|
528
|
+
log,
|
|
529
|
+
report: result.report,
|
|
530
|
+
runsDir,
|
|
531
|
+
runsFs: deps.runsFs,
|
|
532
|
+
tracker: deps.tracker,
|
|
533
|
+
});
|
|
534
|
+
if (yielded) {
|
|
535
|
+
return { applied: undefined, cwd, issue, resolved, result };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// TELEMETRY (opt-in): a compact token/cost line for the writeback comment,
|
|
539
|
+
// Built only when the project opts in AND the agent reported usage. Undefined
|
|
540
|
+
// Otherwise so applyReport appends nothing. Cost is included only when the
|
|
541
|
+
// Usage_update event itself carried one (no pricing table is invented here).
|
|
542
|
+
const telemetryEnabled = resolveTelemetryInComment(deps.config, deps.registry, projectKeyOf(key));
|
|
543
|
+
const telemetryModel = deps.config.agents[effectiveAgent]?.model;
|
|
544
|
+
function telemetryLine(): string | undefined {
|
|
545
|
+
if (!telemetryEnabled || result.stream.usage === undefined) {
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
return formatTelemetryLine(result.stream.usage, telemetryModel, record.attempts);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// QUALITY GATE (opt-in, autonomous-only): before an implement `done` report is
|
|
552
|
+
// Allowed to open a PR / advance to In Review, run the project check command(s) in
|
|
553
|
+
// The worktree. On RED, re-prompt the SAME live agent session once with the failing
|
|
554
|
+
// Output; re-check. Still red (or the rework didn't re-emit `done`) → treat as failed.
|
|
555
|
+
const gateCommands = resolveQualityGate(deps.config, deps.registry, projectKeyOf(key));
|
|
556
|
+
if (effectiveJobKind === "implement" && result.report?.status === "done" && gateCommands.length > 0) {
|
|
557
|
+
const gateExec = deps.gateExec ?? defaultGateExec;
|
|
558
|
+
let gate: { output: string; passed: boolean } | undefined;
|
|
559
|
+
try {
|
|
560
|
+
gate = await runQualityGate(gateCommands, cwd, gateExec);
|
|
561
|
+
} catch (err) {
|
|
562
|
+
// (b) The gate RUNNER itself threw (couldn't run the command) — fail OPEN:
|
|
563
|
+
// The gate is an enhancement, not a correctness oracle. Log and proceed as done.
|
|
564
|
+
log(
|
|
565
|
+
`beflow: ${key} quality gate could not run: ${err instanceof Error ? err.message : String(err)}; proceeding as done`,
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
if (gate !== undefined && !gate.passed) {
|
|
569
|
+
// (a) The gate RAN and returned RED — enforce. Auto-rework once against the
|
|
570
|
+
// Same session, injecting the failing output as a continuation, then re-check.
|
|
571
|
+
log(`beflow: ${key} quality gate failed; auto-reworking once`);
|
|
572
|
+
const gateComment: Comment = {
|
|
573
|
+
body: `The quality gate failed. Fix these and re-emit the report block:\n${gate.output}`,
|
|
574
|
+
createdAt: clock(),
|
|
575
|
+
id: "quality-gate",
|
|
576
|
+
isBot: false,
|
|
577
|
+
};
|
|
578
|
+
const reworkTask = renderContinuation(deps.prompts, {
|
|
579
|
+
newComments: [gateComment],
|
|
580
|
+
...(result.report.prUrl !== undefined ? { prUrl: result.report.prUrl } : {}),
|
|
581
|
+
priorReport: result.report,
|
|
582
|
+
});
|
|
583
|
+
const reworked = await deps.driver.run(buildRunOptions(reworkTask), (evt) => {
|
|
584
|
+
log(`acpx: ${JSON.stringify(evt)}`);
|
|
585
|
+
});
|
|
586
|
+
// The rework re-prompt can take minutes; re-assert the human-authoritative
|
|
587
|
+
// Yield check (as after the initial dispatch) so a card pulled out of the
|
|
588
|
+
// Started group during rework is not clobbered by applyReport below.
|
|
589
|
+
const reworkYielded = await yieldToManualMove({
|
|
590
|
+
cleanup:
|
|
591
|
+
(worktreeCreated || isResume) && git !== undefined
|
|
592
|
+
? async (): Promise<void> => {
|
|
593
|
+
await removeWorktree(effectiveRepoPath, cwd, git);
|
|
594
|
+
}
|
|
595
|
+
: undefined,
|
|
596
|
+
issue,
|
|
597
|
+
key,
|
|
598
|
+
log,
|
|
599
|
+
report: reworked.report,
|
|
600
|
+
runsDir,
|
|
601
|
+
runsFs: deps.runsFs,
|
|
602
|
+
tracker: deps.tracker,
|
|
603
|
+
});
|
|
604
|
+
if (reworkYielded) {
|
|
605
|
+
return { applied: undefined, cwd, issue, resolved, result: reworked };
|
|
606
|
+
}
|
|
607
|
+
let reworkGate: { output: string; passed: boolean } | undefined;
|
|
608
|
+
if (reworked.report?.status === "done") {
|
|
609
|
+
try {
|
|
610
|
+
reworkGate = await runQualityGate(gateCommands, cwd, gateExec);
|
|
611
|
+
} catch (err) {
|
|
612
|
+
log(
|
|
613
|
+
`beflow: ${key} quality gate could not run on rework: ${err instanceof Error ? err.message : String(err)}; proceeding as done`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (reworked.report?.status === "done" && (reworkGate === undefined || reworkGate.passed)) {
|
|
618
|
+
// Rework produced a fresh `done` report AND the gate is green (or the
|
|
619
|
+
// Re-run threw → fail open) — adopt the new report and fall through.
|
|
620
|
+
result = reworked;
|
|
621
|
+
} else {
|
|
622
|
+
// Still red, or the rework didn't re-emit a `done` report → FAILED. Route
|
|
623
|
+
// Through applyReport(failed) + escalation, and persist the run record with
|
|
624
|
+
// The unified attempt counter INCREMENTED (not reset) so repeated gate
|
|
625
|
+
// Failures across dispatches accumulate toward the quarantine threshold.
|
|
626
|
+
const failedOutput = reworkGate !== undefined && !reworkGate.passed ? reworkGate.output : gate.output;
|
|
627
|
+
const failedReport: Report = {
|
|
628
|
+
status: "failed",
|
|
629
|
+
summary: `Quality gate failed after auto-rework:\n${failedOutput}`,
|
|
630
|
+
};
|
|
631
|
+
const failedUsage = reworked.stream.usage;
|
|
632
|
+
const failedTelemetry =
|
|
633
|
+
telemetryEnabled && failedUsage !== undefined
|
|
634
|
+
? formatTelemetryLine(failedUsage, telemetryModel, record.attempts)
|
|
635
|
+
: undefined;
|
|
636
|
+
const failedApplied = await applyReport(
|
|
637
|
+
deps.tracker,
|
|
638
|
+
issue,
|
|
639
|
+
failedReport,
|
|
640
|
+
effectiveJobKind,
|
|
641
|
+
failedTelemetry,
|
|
642
|
+
);
|
|
643
|
+
saveRecord(
|
|
644
|
+
runsDir,
|
|
645
|
+
{
|
|
646
|
+
...record,
|
|
647
|
+
attempts: (record.attempts ?? 0) + 1,
|
|
648
|
+
report: failedReport,
|
|
649
|
+
status: "failed",
|
|
650
|
+
updatedAt: clock(),
|
|
651
|
+
...(failedUsage !== undefined ? { usage: failedUsage } : {}),
|
|
652
|
+
},
|
|
653
|
+
deps.runsFs,
|
|
654
|
+
);
|
|
655
|
+
await notifyEscalation(deps.notify, issue, "failed", escalationDetail(failedReport));
|
|
656
|
+
log(`beflow: ${key} quality gate still failing after auto-rework; parked as failed`);
|
|
657
|
+
return { applied: failedApplied, cwd, issue, resolved, result: reworked };
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
let applied: WritebackResult | undefined;
|
|
663
|
+
if (result.report !== null) {
|
|
664
|
+
applied = await applyReport(deps.tracker, issue, result.report, effectiveJobKind, telemetryLine());
|
|
665
|
+
} else {
|
|
666
|
+
log(`beflow: ${key} produced no report; left In Progress`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const reportStatus = result.report?.status;
|
|
670
|
+
if (reportStatus === "done") {
|
|
671
|
+
if (effectiveJobKind === "implement") {
|
|
672
|
+
// Implement done → In Review: keep the worktree + record (with the PR and
|
|
673
|
+
// report) so a changes-requested rework can resume in place. Cleanup happens
|
|
674
|
+
// at Done (PR merged), handled by watch.
|
|
675
|
+
saveRecord(
|
|
676
|
+
runsDir,
|
|
677
|
+
{
|
|
678
|
+
...record,
|
|
679
|
+
attempts: 0,
|
|
680
|
+
status: "done",
|
|
681
|
+
updatedAt: clock(),
|
|
682
|
+
...(result.report !== null ? { report: result.report } : {}),
|
|
683
|
+
...(result.report?.prUrl !== undefined ? { prUrl: result.report.prUrl } : {}),
|
|
684
|
+
...(result.stream.usage !== undefined ? { usage: result.stream.usage } : {}),
|
|
685
|
+
},
|
|
686
|
+
deps.runsFs,
|
|
687
|
+
);
|
|
688
|
+
await postInReviewInstructionOnce(deps.tracker, issue, log);
|
|
689
|
+
} else {
|
|
690
|
+
if (worktreeCreated || isResume) {
|
|
691
|
+
if (git !== undefined) {
|
|
692
|
+
await removeWorktree(effectiveRepoPath, cwd, git);
|
|
693
|
+
log(`beflow: removed worktree at ${cwd}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
deleteRecord(runsDir, key, deps.runsFs);
|
|
697
|
+
}
|
|
698
|
+
} else if (reportStatus !== undefined) {
|
|
699
|
+
saveRecord(
|
|
700
|
+
runsDir,
|
|
701
|
+
{
|
|
702
|
+
...record,
|
|
703
|
+
attempts: 0,
|
|
704
|
+
status: reportStatus,
|
|
705
|
+
updatedAt: clock(),
|
|
706
|
+
...(result.report !== null ? { report: result.report } : {}),
|
|
707
|
+
...(result.report?.prUrl !== undefined ? { prUrl: result.report.prUrl } : {}),
|
|
708
|
+
...(result.stream.usage !== undefined ? { usage: result.stream.usage } : {}),
|
|
709
|
+
},
|
|
710
|
+
deps.runsFs,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (
|
|
715
|
+
(reportStatus === "needs_input" || reportStatus === "blocked" || reportStatus === "failed") &&
|
|
716
|
+
result.report !== null
|
|
717
|
+
) {
|
|
718
|
+
await notifyEscalation(deps.notify, issue, reportStatus, escalationDetail(result.report));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return { applied, cwd, issue, resolved, result };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export interface InteractiveLaunch {
|
|
725
|
+
agent: string;
|
|
726
|
+
sessionKey: string;
|
|
727
|
+
cwd: string;
|
|
728
|
+
contract: string;
|
|
729
|
+
task: string;
|
|
730
|
+
acpCommand: string;
|
|
731
|
+
acpxCommand: string[];
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export type LaunchInteractive = (launch: InteractiveLaunch) => Promise<void>;
|
|
735
|
+
|
|
736
|
+
export interface OutcomeAnswer {
|
|
737
|
+
status: Report["status"];
|
|
738
|
+
prUrl?: string;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export interface OutcomeContext {
|
|
742
|
+
jobKind: JobKind;
|
|
743
|
+
key: string;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export type AskOutcome = (ctx: OutcomeContext) => Promise<OutcomeAnswer>;
|
|
747
|
+
|
|
748
|
+
export interface RunSupervisedDeps {
|
|
749
|
+
tracker: Tracker;
|
|
750
|
+
config: Config;
|
|
751
|
+
registry: Registry;
|
|
752
|
+
prompts: PromptSet;
|
|
753
|
+
launchInteractive?: LaunchInteractive;
|
|
754
|
+
askOutcome?: AskOutcome;
|
|
755
|
+
ensureSession?: (sessionName: string, cwd: string, acpCommand: string) => Promise<void>;
|
|
756
|
+
log?: Logger;
|
|
757
|
+
notify?: Notifier;
|
|
758
|
+
runsFs?: RunStoreFs;
|
|
759
|
+
clock?: Clock;
|
|
760
|
+
preResolved?: ResolvedRun;
|
|
761
|
+
mcpServers?: McpServer[];
|
|
762
|
+
mcpFs?: McpFs;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export interface SupervisedResult {
|
|
766
|
+
issue: Issue;
|
|
767
|
+
resolved: Resolved;
|
|
768
|
+
cwd: string;
|
|
769
|
+
report: Report;
|
|
770
|
+
applied: WritebackResult;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export async function runSupervised(
|
|
774
|
+
key: string,
|
|
775
|
+
cli: Partial<Resolved>,
|
|
776
|
+
deps: RunSupervisedDeps,
|
|
777
|
+
): Promise<SupervisedResult> {
|
|
778
|
+
const log =
|
|
779
|
+
deps.log ??
|
|
780
|
+
((): void => {
|
|
781
|
+
/* no-op: logging disabled */
|
|
782
|
+
});
|
|
783
|
+
const launchInteractive = deps.launchInteractive ?? defaultLaunchInteractive;
|
|
784
|
+
const askOutcome = deps.askOutcome ?? defaultAskOutcome;
|
|
785
|
+
|
|
786
|
+
const { issue, resolved } =
|
|
787
|
+
deps.preResolved ?? (await resolveRun(key, cli, deps.config, deps.registry, deps.tracker));
|
|
788
|
+
|
|
789
|
+
const cwd = resolved.repoPath;
|
|
790
|
+
const runsDir = resolveRunsDir(deps.config.runs?.dir);
|
|
791
|
+
const clock = deps.clock ?? systemClock;
|
|
792
|
+
|
|
793
|
+
await moveToInProgress(deps.tracker, issue);
|
|
794
|
+
if (deps.config.defaults.assignee !== undefined) {
|
|
795
|
+
await deps.tracker.assign(issue, deps.config.defaults.assignee);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const record: RunRecord = {
|
|
799
|
+
agent: resolved.agent,
|
|
800
|
+
cwd,
|
|
801
|
+
jobKind: resolved.jobKind,
|
|
802
|
+
key,
|
|
803
|
+
repoPath: resolved.repoPath,
|
|
804
|
+
runMode: resolved.runMode,
|
|
805
|
+
sessionName: key,
|
|
806
|
+
status: "in_progress",
|
|
807
|
+
tracker: deps.config.tracker,
|
|
808
|
+
updatedAt: clock(),
|
|
809
|
+
};
|
|
810
|
+
saveRecord(runsDir, record, deps.runsFs);
|
|
811
|
+
|
|
812
|
+
const acpCommand = resolveAcpCommand(resolved.agent, deps.config.agents[resolved.agent]);
|
|
813
|
+
|
|
814
|
+
if (deps.ensureSession !== undefined) {
|
|
815
|
+
await deps.ensureSession(key, cwd, acpCommand);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const contract = renderContract(deps.prompts, resolved.jobKind, issue, resolved.repo);
|
|
819
|
+
const task =
|
|
820
|
+
renderTask(deps.prompts, issue, resolved.repo) +
|
|
821
|
+
(await gatherLinkedContext(deps.tracker, issue, deps.config.defaults.linkedContext !== false, log));
|
|
822
|
+
|
|
823
|
+
// Inject the managed `.acpxrc.json` into the repo checkout for the interactive
|
|
824
|
+
// Acpx launch, then restore it in a finally so the user's repo is left exactly
|
|
825
|
+
// As before (this is --attend, in-place: correctness of the restore matters most).
|
|
826
|
+
const mcpCleanup =
|
|
827
|
+
deps.mcpServers !== undefined && deps.mcpServers.length > 0
|
|
828
|
+
? injectAcpxMcp(cwd, deps.mcpServers, deps.mcpFs ?? nodeMcpFs)
|
|
829
|
+
: undefined;
|
|
830
|
+
try {
|
|
831
|
+
await launchInteractive({
|
|
832
|
+
acpCommand,
|
|
833
|
+
acpxCommand: resolveAcpxCommand(deps.config),
|
|
834
|
+
agent: resolved.agent,
|
|
835
|
+
contract,
|
|
836
|
+
cwd,
|
|
837
|
+
sessionKey: key,
|
|
838
|
+
task,
|
|
839
|
+
});
|
|
840
|
+
} finally {
|
|
841
|
+
mcpCleanup?.();
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const outcome = await askOutcome({ jobKind: resolved.jobKind, key });
|
|
845
|
+
const report: Report = {
|
|
846
|
+
status: outcome.status,
|
|
847
|
+
summary: `Supervised run of ${key} ended with status "${outcome.status}".`,
|
|
848
|
+
...(outcome.prUrl !== undefined ? { prUrl: outcome.prUrl } : {}),
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
if (await yieldToManualMove({ issue, key, log, report, runsDir, runsFs: deps.runsFs, tracker: deps.tracker })) {
|
|
852
|
+
return { applied: {}, cwd, issue, report, resolved };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const applied = await applyReport(deps.tracker, issue, report, resolved.jobKind);
|
|
856
|
+
|
|
857
|
+
if (outcome.status === "done") {
|
|
858
|
+
deleteRecord(runsDir, key, deps.runsFs);
|
|
859
|
+
} else {
|
|
860
|
+
saveRecord(
|
|
861
|
+
runsDir,
|
|
862
|
+
{
|
|
863
|
+
...record,
|
|
864
|
+
status: outcome.status,
|
|
865
|
+
updatedAt: clock(),
|
|
866
|
+
report,
|
|
867
|
+
...(report.prUrl !== undefined ? { prUrl: report.prUrl } : {}),
|
|
868
|
+
},
|
|
869
|
+
deps.runsFs,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (outcome.status === "needs_input" || outcome.status === "blocked" || outcome.status === "failed") {
|
|
874
|
+
await notifyEscalation(deps.notify, issue, outcome.status, report.summary);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
log(`beflow: supervised ${key} → ${outcome.status}`);
|
|
878
|
+
return { applied, cwd, issue, report, resolved };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export interface OpenLaunch {
|
|
882
|
+
cwd: string;
|
|
883
|
+
command: string;
|
|
884
|
+
args: string[];
|
|
885
|
+
task: string;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
export type OpenIssue = (launch: OpenLaunch) => Promise<void>;
|
|
889
|
+
|
|
890
|
+
export interface RunOpenDeps {
|
|
891
|
+
tracker: Tracker;
|
|
892
|
+
config: Config;
|
|
893
|
+
registry: Registry;
|
|
894
|
+
prompts: PromptSet;
|
|
895
|
+
openIssue?: OpenIssue;
|
|
896
|
+
askOutcome?: AskOutcome;
|
|
897
|
+
log?: Logger;
|
|
898
|
+
notify?: Notifier;
|
|
899
|
+
runsFs?: RunStoreFs;
|
|
900
|
+
clock?: Clock;
|
|
901
|
+
preResolved?: ResolvedRun;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Launch the agent's NATIVE interactive TUI directly (no acpx). beflow shields
|
|
905
|
+
// Itself from the Ctrl+C meant for the agent: the signal still reaches the child
|
|
906
|
+
// (same foreground group), which handles it; beflow must survive so it can run
|
|
907
|
+
// The outcome prompt + writeback after the agent exits.
|
|
908
|
+
export async function defaultOpenIssue(launch: OpenLaunch): Promise<void> {
|
|
909
|
+
function onSigint(): void {
|
|
910
|
+
/* no-op: keep beflow alive while the child agent handles Ctrl+C */
|
|
911
|
+
}
|
|
912
|
+
process.on("SIGINT", onSigint);
|
|
913
|
+
try {
|
|
914
|
+
const proc = bun.spawn([launch.command, ...launch.args, launch.task], {
|
|
915
|
+
cwd: launch.cwd,
|
|
916
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
917
|
+
});
|
|
918
|
+
await proc.exited;
|
|
919
|
+
} finally {
|
|
920
|
+
process.removeListener("SIGINT", onSigint);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export async function runOpen(key: string, cli: Partial<Resolved>, deps: RunOpenDeps): Promise<SupervisedResult> {
|
|
925
|
+
const log =
|
|
926
|
+
deps.log ??
|
|
927
|
+
((): void => {
|
|
928
|
+
/* no-op: logging disabled */
|
|
929
|
+
});
|
|
930
|
+
const openIssue = deps.openIssue ?? defaultOpenIssue;
|
|
931
|
+
const askOutcome = deps.askOutcome ?? defaultAskOutcome;
|
|
932
|
+
|
|
933
|
+
const { issue, resolved } =
|
|
934
|
+
deps.preResolved ?? (await resolveRun(key, cli, deps.config, deps.registry, deps.tracker));
|
|
935
|
+
|
|
936
|
+
const agentCfg = deps.config.agents[resolved.agent];
|
|
937
|
+
if (agentCfg === undefined) {
|
|
938
|
+
throw new Error(`beflow: agent "${resolved.agent}" is not configured in config.agents`);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const cwd = resolved.repoPath;
|
|
942
|
+
const runsDir = resolveRunsDir(deps.config.runs?.dir);
|
|
943
|
+
const clock = deps.clock ?? systemClock;
|
|
944
|
+
|
|
945
|
+
log(`beflow: ${key} resolved → agent ${resolved.agent}, jobKind ${resolved.jobKind}, repo ${resolved.repo}`);
|
|
946
|
+
|
|
947
|
+
log(`beflow: moving ${key} to In Progress`);
|
|
948
|
+
await moveToInProgress(deps.tracker, issue);
|
|
949
|
+
if (deps.config.defaults.assignee !== undefined) {
|
|
950
|
+
log(`beflow: assigning ${key} to ${deps.config.defaults.assignee}`);
|
|
951
|
+
await deps.tracker.assign(issue, deps.config.defaults.assignee);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const record: RunRecord = {
|
|
955
|
+
agent: resolved.agent,
|
|
956
|
+
cwd,
|
|
957
|
+
jobKind: resolved.jobKind,
|
|
958
|
+
key,
|
|
959
|
+
repoPath: resolved.repoPath,
|
|
960
|
+
runMode: resolved.runMode,
|
|
961
|
+
sessionName: key,
|
|
962
|
+
status: "in_progress",
|
|
963
|
+
tracker: deps.config.tracker,
|
|
964
|
+
updatedAt: clock(),
|
|
965
|
+
};
|
|
966
|
+
saveRecord(runsDir, record, deps.runsFs);
|
|
967
|
+
|
|
968
|
+
log(`beflow: launching ${agentCfg.command} in ${cwd}`);
|
|
969
|
+
const task =
|
|
970
|
+
renderTask(deps.prompts, issue, resolved.repo) +
|
|
971
|
+
(await gatherLinkedContext(deps.tracker, issue, deps.config.defaults.linkedContext !== false, log)) +
|
|
972
|
+
OPEN_SESSION_TRAILER;
|
|
973
|
+
await openIssue({
|
|
974
|
+
args: agentCfg.args ?? [],
|
|
975
|
+
command: agentCfg.command,
|
|
976
|
+
cwd,
|
|
977
|
+
task,
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
const outcome = await askOutcome({ jobKind: resolved.jobKind, key });
|
|
981
|
+
const report: Report = {
|
|
982
|
+
status: outcome.status,
|
|
983
|
+
summary: `Open run of ${key} ended with status "${outcome.status}".`,
|
|
984
|
+
...(outcome.prUrl !== undefined ? { prUrl: outcome.prUrl } : {}),
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
if (await yieldToManualMove({ issue, key, log, report, runsDir, runsFs: deps.runsFs, tracker: deps.tracker })) {
|
|
988
|
+
return { applied: {}, cwd, issue, report, resolved };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const applied = await applyReport(deps.tracker, issue, report, resolved.jobKind);
|
|
992
|
+
|
|
993
|
+
if (outcome.status === "done") {
|
|
994
|
+
deleteRecord(runsDir, key, deps.runsFs);
|
|
995
|
+
} else {
|
|
996
|
+
saveRecord(
|
|
997
|
+
runsDir,
|
|
998
|
+
{
|
|
999
|
+
...record,
|
|
1000
|
+
status: outcome.status,
|
|
1001
|
+
updatedAt: clock(),
|
|
1002
|
+
report,
|
|
1003
|
+
...(report.prUrl !== undefined ? { prUrl: report.prUrl } : {}),
|
|
1004
|
+
},
|
|
1005
|
+
deps.runsFs,
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (outcome.status === "needs_input" || outcome.status === "blocked" || outcome.status === "failed") {
|
|
1010
|
+
await notifyEscalation(deps.notify, issue, outcome.status, report.summary);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
log(`beflow: ${key} → ${outcome.status}${applied.movedTo !== undefined ? ` (${applied.movedTo})` : ""}`);
|
|
1014
|
+
return { applied, cwd, issue, report, resolved };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
export function buildInteractiveArgs(launch: InteractiveLaunch): string[] {
|
|
1018
|
+
return [
|
|
1019
|
+
...launch.acpxCommand,
|
|
1020
|
+
"--approve-reads",
|
|
1021
|
+
"--cwd",
|
|
1022
|
+
launch.cwd,
|
|
1023
|
+
"--append-system-prompt",
|
|
1024
|
+
launch.contract,
|
|
1025
|
+
"--agent",
|
|
1026
|
+
launch.acpCommand,
|
|
1027
|
+
"prompt",
|
|
1028
|
+
"-s",
|
|
1029
|
+
launch.sessionKey,
|
|
1030
|
+
launch.task,
|
|
1031
|
+
];
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async function defaultLaunchInteractive(launch: InteractiveLaunch): Promise<void> {
|
|
1035
|
+
const proc = bun.spawn(buildInteractiveArgs(launch), { stdio: ["inherit", "inherit", "inherit"] });
|
|
1036
|
+
await proc.exited;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function defaultAskOutcome(ctx: OutcomeContext): Promise<OutcomeAnswer> {
|
|
1040
|
+
intro(`Outcome for ${ctx.key}`);
|
|
1041
|
+
|
|
1042
|
+
const status = await select({
|
|
1043
|
+
message: `How did ${ctx.key} go?`,
|
|
1044
|
+
options: [
|
|
1045
|
+
{ value: "done", label: "done", hint: `finished (→ ${defaultDoneState(ctx.jobKind)})` },
|
|
1046
|
+
{ value: "needs_input", label: "needs_input", hint: "a human decision is required (→ Needs Input)" },
|
|
1047
|
+
{ value: "blocked", label: "blocked", hint: "waiting on a dependency (+blocked label)" },
|
|
1048
|
+
{ value: "failed", label: "failed", hint: "could not complete (stays put)" },
|
|
1049
|
+
],
|
|
1050
|
+
});
|
|
1051
|
+
if (isCancel(status)) {
|
|
1052
|
+
cancel(`beflow: outcome cancelled — ${ctx.key} left In Progress (resume: beflow run ${ctx.key} --open)`);
|
|
1053
|
+
throw new Error(`beflow: outcome cancelled for ${ctx.key}`);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
let prUrl: string | undefined;
|
|
1057
|
+
if (ctx.jobKind === "implement") {
|
|
1058
|
+
const answer = await text({
|
|
1059
|
+
message: "PR URL (blank for none)",
|
|
1060
|
+
placeholder: "https://…",
|
|
1061
|
+
defaultValue: "",
|
|
1062
|
+
});
|
|
1063
|
+
if (isCancel(answer)) {
|
|
1064
|
+
cancel(`beflow: outcome cancelled — ${ctx.key} left In Progress (resume: beflow run ${ctx.key} --open)`);
|
|
1065
|
+
throw new Error(`beflow: outcome cancelled for ${ctx.key}`);
|
|
1066
|
+
}
|
|
1067
|
+
const trimmed = answer.trim();
|
|
1068
|
+
if (trimmed !== "") {
|
|
1069
|
+
prUrl = trimmed;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
outro("Writing back…");
|
|
1074
|
+
return { status: status as ReportStatus, ...(prUrl !== undefined ? { prUrl } : {}) };
|
|
1075
|
+
}
|