@voidlabs.gg/voidshift-agents 0.7.0 → 0.8.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/README.md +31 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +11 -0
- package/dist/control.d.ts +19 -0
- package/dist/control.js +87 -19
- package/dist/engine.d.ts +22 -0
- package/dist/engine.js +144 -7
- package/dist/git.d.ts +1 -0
- package/dist/git.js +5 -0
- package/dist/manage.d.ts +2 -2
- package/dist/manage.js +21 -5
- package/dist/notify.js +3 -3
- package/dist/runner.d.ts +5 -1
- package/dist/runner.js +215 -40
- package/dist/service.js +1 -1
- package/dist/state.d.ts +40 -1
- package/dist/state.js +116 -0
- package/dist/systemd.d.ts +2 -0
- package/dist/systemd.js +14 -0
- package/dist/usage.d.ts +18 -0
- package/dist/usage.js +102 -0
- package/package.json +1 -1
package/dist/manage.js
CHANGED
|
@@ -29,13 +29,25 @@ export function validateStep(step, config, dir) {
|
|
|
29
29
|
problems.push(`${name} prompt must be a file name.`);
|
|
30
30
|
if (step.skill !== undefined && typeof step.skill !== 'string')
|
|
31
31
|
problems.push(`${name} skill must be a name or a folder.`);
|
|
32
|
-
|
|
32
|
+
if (step.run !== undefined && (typeof step.run !== 'string' || !step.run.trim()))
|
|
33
|
+
problems.push(`${name} run must be a command.`);
|
|
34
|
+
if (step.handoff !== undefined) {
|
|
35
|
+
if (typeof step.handoff !== 'string' || !step.handoff.trim())
|
|
36
|
+
problems.push(`${name} handoff must be a workflow name.`);
|
|
37
|
+
else if (config.workflows && !Object.hasOwn(config.workflows, step.handoff))
|
|
38
|
+
problems.push(`${name} hands cards to ${step.handoff}, which is not a workflow.`);
|
|
39
|
+
}
|
|
40
|
+
const sources = [step.skill, step.prompt, step.run].filter((v) => v !== undefined).length;
|
|
33
41
|
if (step.output === 'publish') {
|
|
34
42
|
if (sources)
|
|
35
|
-
problems.push(`${name} only pushes the branch, so it takes no skill or
|
|
43
|
+
problems.push(`${name} only pushes the branch, so it takes no skill, prompt or run.`);
|
|
36
44
|
}
|
|
37
45
|
else if (sources !== 1)
|
|
38
|
-
problems.push(`${name} needs exactly one of skill or
|
|
46
|
+
problems.push(`${name} needs exactly one of skill, prompt or run.`);
|
|
47
|
+
else if (step.run !== undefined) {
|
|
48
|
+
if (step.output !== 'none')
|
|
49
|
+
problems.push(`${name} runs a command, so its output must be none.`);
|
|
50
|
+
}
|
|
39
51
|
else if (typeof (step.prompt ?? step.skill) === 'string') {
|
|
40
52
|
const file = stepFile(step, dir);
|
|
41
53
|
if (!file || !existsSync(file))
|
|
@@ -121,8 +133,8 @@ export function validateAgentFolder(dir, config) {
|
|
|
121
133
|
catch (err) {
|
|
122
134
|
return [`${AGENT_FILE}: ${err.message}`];
|
|
123
135
|
}
|
|
124
|
-
const problems = validateWorkflow(config, workflow, dir);
|
|
125
136
|
const name = basename(dir);
|
|
137
|
+
const problems = validateWorkflow({ ...config, workflows: { ...config.workflows, [name]: workflow } }, workflow, dir);
|
|
126
138
|
const from = config.folders?.[name];
|
|
127
139
|
if (config.workflows[name] && (!from || resolve(from) !== resolve(dir)))
|
|
128
140
|
problems.push(`config.json already has a workflow named ${name}, and it wins. Rename the folder.`);
|
|
@@ -228,6 +240,10 @@ export function validateConfig(value, saved) {
|
|
|
228
240
|
problems.push('reviewState must be a non-empty string.');
|
|
229
241
|
if (!isInt(value.timeoutMinutes, 1, 480))
|
|
230
242
|
problems.push('timeoutMinutes must be a whole number from 1 to 480.');
|
|
243
|
+
if (value.maxRuns !== undefined && !isInt(value.maxRuns, 0, 32))
|
|
244
|
+
problems.push('maxRuns must be a whole number from 0 to 32, 0 for no cap.');
|
|
245
|
+
if (value.pauseAt !== undefined && !isInt(value.pauseAt, 0, 100))
|
|
246
|
+
problems.push('pauseAt must be a whole number from 0 to 100, 0 to never hold.');
|
|
231
247
|
const projects = isObj(value.projects) ? value.projects : null;
|
|
232
248
|
if (!projects)
|
|
233
249
|
problems.push('projects must be an object.');
|
|
@@ -263,7 +279,7 @@ export function validateConfig(value, saved) {
|
|
|
263
279
|
if (!isObj(value.workflows))
|
|
264
280
|
problems.push('workflows must be an object.');
|
|
265
281
|
else {
|
|
266
|
-
const ctx = { enabled: Array.isArray(value.enabled) ? value.enabled : [], projects: (projects ?? {}) };
|
|
282
|
+
const ctx = { enabled: Array.isArray(value.enabled) ? value.enabled : [], projects: (projects ?? {}), workflows: value.workflows };
|
|
267
283
|
for (const [name, w] of Object.entries(value.workflows)) {
|
|
268
284
|
// Folder workflows are checked with their folder, by vsf agents validate.
|
|
269
285
|
if (isObj(value.folders) && value.folders[name])
|
package/dist/notify.js
CHANGED
|
@@ -96,7 +96,7 @@ const SUMMARY_HOUR = 9;
|
|
|
96
96
|
const DAY = 86_400_000;
|
|
97
97
|
const localDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
98
98
|
// Runs ended since the last summary (or the last day), counted by outcome,
|
|
99
|
-
// with the pull requests and parked cards.
|
|
99
|
+
// with the pull requests and the parked or failed cards.
|
|
100
100
|
export function summaryMessage(configDir, since, paused) {
|
|
101
101
|
const runs = listRuns(configDir)
|
|
102
102
|
.map((e) => e.record)
|
|
@@ -107,14 +107,14 @@ export function summaryMessage(configDir, since, paused) {
|
|
|
107
107
|
for (const r of runs)
|
|
108
108
|
counts.set(r.outcome, (counts.get(r.outcome) ?? 0) + 1);
|
|
109
109
|
const prs = runs.filter((r) => r.outcome === 'verified' && r.prUrl).slice(0, 10);
|
|
110
|
-
const parked = runs.filter((r) => r.outcome === 'parked').slice(0, 10);
|
|
110
|
+
const parked = runs.filter((r) => r.outcome === 'parked' || r.outcome === 'failed').slice(0, 10);
|
|
111
111
|
const lines = [`**Daily summary** ${[...counts].map(([o, n]) => `${n} ${o}`).join(', ')}`];
|
|
112
112
|
if (paused)
|
|
113
113
|
lines.push('Runner paused');
|
|
114
114
|
if (prs.length)
|
|
115
115
|
lines.push('PRs:', ...prs.map((r) => `${r.card} ${r.prUrl}`));
|
|
116
116
|
if (parked.length)
|
|
117
|
-
lines.push('Parked:', ...parked.map((r) => `${r.card} ${r.title}${r.reason ? `: ${r.reason.slice(0, 120)}` : ''}`));
|
|
117
|
+
lines.push('Parked or failed:', ...parked.map((r) => `${r.card} ${r.title}${r.reason ? `: ${r.reason.slice(0, 120)}` : ''}`));
|
|
118
118
|
return lines.join('\n');
|
|
119
119
|
}
|
|
120
120
|
// Sends the summary once a day, on the first call at or after 09:00 local.
|
package/dist/runner.d.ts
CHANGED
|
@@ -10,11 +10,13 @@ export interface RunContext {
|
|
|
10
10
|
selfCommand: string[];
|
|
11
11
|
notify?: (event: RunEvent) => Promise<void>;
|
|
12
12
|
summary?: (paused: boolean) => Promise<void>;
|
|
13
|
+
platform?: NodeJS.Platform;
|
|
13
14
|
}
|
|
14
15
|
export declare function summarize(output: string): string;
|
|
16
|
+
export declare function lastLine(output: string): string;
|
|
15
17
|
export declare function runCard(card: Card, trigger: Trigger, workflowName: string, workflow: Workflow, project: Project, rc: RunContext, opts?: {
|
|
16
18
|
from?: string;
|
|
17
|
-
}): Promise<'verified' | 'changes' | 'parked'>;
|
|
19
|
+
}): Promise<'verified' | 'done' | 'changes' | 'parked' | 'failed'>;
|
|
18
20
|
type Job = {
|
|
19
21
|
card: Card;
|
|
20
22
|
trigger: Trigger;
|
|
@@ -35,6 +37,7 @@ export interface JobQuery {
|
|
|
35
37
|
export declare function findJobs(rc: RunContext, q?: JobQuery): Promise<Job[]>;
|
|
36
38
|
export declare function nextJob(rc: RunContext, only?: string, workflowName?: string, opts?: Omit<JobQuery, 'only' | 'workflow' | 'limit'>): Promise<Job | null>;
|
|
37
39
|
export declare function cancelCard(tracker: Tracker, configDir: string, cardId: string, label: string, source?: 'linear' | 'voidtrack'): Promise<boolean>;
|
|
40
|
+
export declare function gateReason(rc: Pick<RunContext, 'configDir' | 'config'>, now?: Date): string | null;
|
|
38
41
|
export declare function tick(rc: RunContext, only?: string, opts?: {
|
|
39
42
|
workflow?: string;
|
|
40
43
|
from?: string;
|
|
@@ -50,6 +53,7 @@ export interface DispatchDeps {
|
|
|
50
53
|
start: (args: string[]) => ChildRun;
|
|
51
54
|
waitMs: number;
|
|
52
55
|
reload?: () => AgentsConfig;
|
|
56
|
+
window: () => Promise<unknown>;
|
|
53
57
|
}
|
|
54
58
|
export declare const jobArgs: (job: Job) => string[];
|
|
55
59
|
export declare function dispatch(rc: RunContext, over?: Partial<DispatchDeps>): Promise<number>;
|
package/dist/runner.js
CHANGED
|
@@ -2,16 +2,22 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
4
|
import { LABELS, PLAN_HEADING, buildPrompt, findingsFor, fixTarget, isStatusLabel, latestPlan, needsPlan, parseDevResult, parseVerdict, planBody, qaPassed, stageLabels, verdictBlock, } from "./workflow.js";
|
|
5
|
-
import { agentsDir, hasWork, isCardTrigger, resolveProject, resolveStep, stepId, stepIdLabel, workspaceContext, workspaceOf } from "./config.js";
|
|
5
|
+
import { DEFAULTS, agentsDir, hasWork, isCardTrigger, resolveProject, resolveStep, stepId, stepIdLabel, workspaceContext, workspaceOf } from "./config.js";
|
|
6
6
|
import { lastDue, parseCron } from "./cron.js";
|
|
7
|
-
import { runAgent, stopAgents } from "./engine.js";
|
|
8
|
-
import { commitsAhead, enforceCommitPolicy, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
|
|
7
|
+
import { runAgent, runCommand, stopAgents } from "./engine.js";
|
|
8
|
+
import { commitsAhead, enforceCommitPolicy, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, removeWorktree, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
|
|
9
9
|
import { noTracker } from "./tracker.js";
|
|
10
10
|
import { recall, syncMemory, vaultPath } from "./memory.js";
|
|
11
11
|
import { claudeSettingsFile, readRules, rulesSection } from "./profile.js";
|
|
12
|
-
import { acquireWorkspace, clearCurrent, heldByOther, isPaused, liveRuns, readCurrent, readPending, releaseWorkspace, writeCurrent, writePending, writeRun, writeWorkspace, readCronState, writeCronState } from "./state.js";
|
|
12
|
+
import { acquireWorkspace, addUsage, clearCurrent, closeStale, heldByOther, isPaused, liveRuns, queueHandoffs, readCurrent, readHandoffs, readPending, readWindow, releaseWorkspace, takeHandoff, writeHandoffs, writeCurrent, writePending, writeRun, writeWorkspace, readCronState, writeCronState } from "./state.js";
|
|
13
|
+
import { WINDOW_MS, clock, refreshWindow, windowHold, windowStale } from "./usage.js";
|
|
13
14
|
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
|
|
14
15
|
class Park extends Error {
|
|
16
|
+
kind;
|
|
17
|
+
constructor(message, kind = 'parked') {
|
|
18
|
+
super(message);
|
|
19
|
+
this.kind = kind;
|
|
20
|
+
}
|
|
15
21
|
}
|
|
16
22
|
function withLabels(card, drop, add) {
|
|
17
23
|
return [...new Set([...card.labels.filter((l) => !drop(l)), ...add])];
|
|
@@ -21,7 +27,9 @@ const UNATTENDED = `# Running unattended
|
|
|
21
27
|
You are one step in a workflow. Nobody can answer questions and the
|
|
22
28
|
tracker connection is not available: the card and any plan are included above.
|
|
23
29
|
Do not write plan files; return your output as your final message. Make the
|
|
24
|
-
call the code supports and state it
|
|
30
|
+
call the code supports and state it. If the environment stops you (a missing
|
|
31
|
+
command, denied access, no network), name the command and the error in your
|
|
32
|
+
final message, and in a Plan or Dev step end with \`RESULT: BLOCKED\`.`;
|
|
25
33
|
// Agents keep the memory vault the way you do: durable learnings become notes.
|
|
26
34
|
const memoryNote = (vault) => `# Memory
|
|
27
35
|
|
|
@@ -34,6 +42,10 @@ rather than adding a second one. Skip it when nothing durable came up.`;
|
|
|
34
42
|
export function summarize(output) {
|
|
35
43
|
return output.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 3).join(' ').slice(0, 300);
|
|
36
44
|
}
|
|
45
|
+
// The last non-empty line of what a command printed, for run.json.
|
|
46
|
+
export function lastLine(output) {
|
|
47
|
+
return output.split('\n').map((l) => l.trim()).filter(Boolean).at(-1)?.slice(0, 300) ?? '';
|
|
48
|
+
}
|
|
37
49
|
// The run in progress, so a cancel signal can mark it.
|
|
38
50
|
let active = null;
|
|
39
51
|
const isVerdict = (d) => d.output === 'verdict';
|
|
@@ -55,6 +67,7 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
55
67
|
// Write back settings apply to card runs; a scheduled run has nothing to write to.
|
|
56
68
|
const writeBack = isCardTrigger(trigger) ? (workflow.writeBack ?? {}) : {};
|
|
57
69
|
const labelsOn = writeBack.labels !== false;
|
|
70
|
+
const cronRun = trigger.source === 'cron';
|
|
58
71
|
const startedAt = new Date().toISOString();
|
|
59
72
|
const workspace = workspaceOf(project);
|
|
60
73
|
const record = { card: card.identifier, title: card.title, url: card.url || undefined, workflow: workflowName, trigger: label || (trigger.source === 'cron' ? trigger.schedule : ''), source: trigger.source, steps: [], outcome: 'running', workspace, startedAt };
|
|
@@ -73,11 +86,11 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
73
86
|
save();
|
|
74
87
|
return rec;
|
|
75
88
|
};
|
|
76
|
-
const end = (rec, result, output, logFile) => {
|
|
89
|
+
const end = (rec, result, output, logFile, command = false) => {
|
|
77
90
|
rec.endedAt = new Date().toISOString();
|
|
78
91
|
rec.result = result;
|
|
79
92
|
if (output !== undefined)
|
|
80
|
-
rec.summary = summarize(output);
|
|
93
|
+
rec.summary = command ? lastLine(output) : summarize(output);
|
|
81
94
|
if (logFile)
|
|
82
95
|
rec.log = basename(logFile);
|
|
83
96
|
save();
|
|
@@ -122,7 +135,9 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
122
135
|
}
|
|
123
136
|
};
|
|
124
137
|
async function park(reason, kind = 'parked') {
|
|
125
|
-
say(
|
|
138
|
+
say(`${kind === 'failed' ? 'failed' : 'parked'}: ${reason}`);
|
|
139
|
+
// The outcome is on disk before any tracker call, so a hung or failed write never loses it.
|
|
140
|
+
const outcome = done(kind, reason);
|
|
126
141
|
const comment = `**Parked:** ${reason}${lastFindings ? `\n\n${lastFindings}` : ''}\n\nFix the cause, then add \`${label}\` again to retry.`;
|
|
127
142
|
const add = labelsOn ? [LABELS.parked, ...(loops ? [LABELS.version(loops)] : [])] : [];
|
|
128
143
|
let commented = false;
|
|
@@ -139,10 +154,11 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
139
154
|
writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [label], add, dropStatus: labelsOn, source: trigger.source }]);
|
|
140
155
|
}
|
|
141
156
|
}
|
|
142
|
-
const outcome = done('parked', reason);
|
|
143
157
|
await ping(kind, reason);
|
|
144
158
|
return outcome;
|
|
145
159
|
}
|
|
160
|
+
// Set once a worktree exists, so a scheduled run can remove it at the end.
|
|
161
|
+
let worktreeMade = '';
|
|
146
162
|
try {
|
|
147
163
|
if (!existsSync(join(project.path, '.git')))
|
|
148
164
|
throw new Park(`The project folder ${project.path} is not a git repository on this Mac.`);
|
|
@@ -150,6 +166,8 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
150
166
|
const from = opts.from ? steps.findIndex((d) => d.id === opts.from) : 0;
|
|
151
167
|
if (from < 0)
|
|
152
168
|
throw new Park(`Workflow ${workflowName} has no ${stepIdLabel(opts.from)} step.`);
|
|
169
|
+
// Command steps need no branch of their own: a run of only those works in the project folder.
|
|
170
|
+
const commandOnly = steps.slice(from).every((d) => d.command);
|
|
153
171
|
// A scheduled run has no card to plan on, so its task is the plan unless a Plan step writes one.
|
|
154
172
|
let plan = isCardTrigger(trigger) ? latestPlan(await linear.comments(card.identifier)) : card.description ?? null;
|
|
155
173
|
if (!plan && needsPlan(steps, from)) {
|
|
@@ -173,15 +191,53 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
173
191
|
say(pulled);
|
|
174
192
|
const context = workspaceContext(config, workspace);
|
|
175
193
|
const known = [recall(rc.selfCommand, rc.configDir, `${workflow.project} ${card.title}\n${card.description ?? ''}`), context ? `# Workspace\n\n${context}` : '', vault ? memoryNote(vault) : '', rules].filter(Boolean).join('\n\n');
|
|
176
|
-
const worktree = join(agentsDir(rc.configDir), 'worktrees', card.identifier);
|
|
177
|
-
|
|
178
|
-
|
|
194
|
+
const worktree = commandOnly ? project.path : join(agentsDir(rc.configDir), 'worktrees', card.identifier);
|
|
195
|
+
if (!commandOnly) {
|
|
196
|
+
ensureWorktree(project.path, worktree, card.branchName, base);
|
|
197
|
+
worktreeMade = worktree;
|
|
198
|
+
say(`working in ${worktree} on ${card.branchName}`);
|
|
199
|
+
}
|
|
179
200
|
const findings = new Map();
|
|
180
201
|
const runs = { plan: 0, dev: 0 };
|
|
181
202
|
let prUrl = '';
|
|
182
203
|
let logs = 0;
|
|
183
204
|
const planText = () => plan ?? '(No plan was posted on this card. Check the work against the card itself.)';
|
|
184
205
|
const logFile = (name) => join(runDir, `${String(logs++).padStart(2, '0')}-${name}.log`);
|
|
206
|
+
// Every agent step goes through here. A read step's stray edits are thrown
|
|
207
|
+
// away, so a later Dev step never commits them, and an agent process that
|
|
208
|
+
// fails ends the run as failed with its own error.
|
|
209
|
+
async function agent(def, rec, log, prompt) {
|
|
210
|
+
const r = def.command
|
|
211
|
+
? await runCommand({ command: def.command, cwd: worktree, logFile: log, timeoutMs: def.timeoutMs, env: { VSF_CARD: card.identifier, VSF_RUN_DIR: runDir, VSF_WORKFLOW: workflowName } })
|
|
212
|
+
: await runAgent({ engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs, platform: rc.platform, prompt });
|
|
213
|
+
// Saved with the step's end, failed or not.
|
|
214
|
+
if (r.usage) {
|
|
215
|
+
rec.usage = r.usage;
|
|
216
|
+
record.usage = addUsage(record.usage, r.usage);
|
|
217
|
+
}
|
|
218
|
+
if (!def.command && (!def.write || def.builtin === 'vs-qa'))
|
|
219
|
+
resetWorktree(worktree);
|
|
220
|
+
if (!r.ok) {
|
|
221
|
+
end(rec, 'failed', r.output, log, !!def.command);
|
|
222
|
+
throw new Park(`The ${def.label} step failed: ${r.error}. Log: ${basename(log)}`, 'failed');
|
|
223
|
+
}
|
|
224
|
+
return r;
|
|
225
|
+
}
|
|
226
|
+
// Cards a finished step names with HANDOFF lines go to the workflow it hands to.
|
|
227
|
+
async function handOff(def, rec, output) {
|
|
228
|
+
if (!def.handoff)
|
|
229
|
+
return;
|
|
230
|
+
if (!config.workflows[def.handoff]) {
|
|
231
|
+
say(`cannot hand cards to ${def.handoff}: no such workflow`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const cards = [...new Set([...output.matchAll(/^\s*HANDOFF:\s*([A-Za-z][A-Za-z0-9]*-\d+)\s*$/gm)].map((m) => m[1].toUpperCase()))];
|
|
235
|
+
queueHandoffs(rc.configDir, def.handoff, cards, card.identifier);
|
|
236
|
+
rec.handoffs = cards;
|
|
237
|
+
save();
|
|
238
|
+
if (cards.length)
|
|
239
|
+
say(`handed ${cards.join(', ')} to ${def.handoff}`);
|
|
240
|
+
}
|
|
185
241
|
async function check(def, round) {
|
|
186
242
|
const label = def.label;
|
|
187
243
|
const rec = begin(def);
|
|
@@ -215,14 +271,10 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
215
271
|
}
|
|
216
272
|
}
|
|
217
273
|
const log = logFile(def.id);
|
|
218
|
-
const r = await
|
|
219
|
-
engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
|
|
220
|
-
prompt: buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
221
|
-
});
|
|
222
|
-
if (def.builtin === 'vs-qa')
|
|
223
|
-
resetWorktree(worktree);
|
|
274
|
+
const r = await agent(def, rec, log, buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }));
|
|
224
275
|
const verdict = parseVerdict(r.output);
|
|
225
276
|
end(rec, verdict, r.output, log);
|
|
277
|
+
await handOff(def, rec, r.output);
|
|
226
278
|
return { step: label, verdict, report: r.output };
|
|
227
279
|
}
|
|
228
280
|
let i = from;
|
|
@@ -267,14 +319,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
267
319
|
await stage(stageOf(def), runs.plan + 1);
|
|
268
320
|
const rec = begin(def);
|
|
269
321
|
const log = logFile(def.builtin ? 'plan' : def.id);
|
|
270
|
-
const r = await
|
|
271
|
-
engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
|
|
272
|
-
prompt: buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none yet, you are writing it)', base, round: runs.plan + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
273
|
-
});
|
|
322
|
+
const r = await agent(def, rec, log, buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none yet, you are writing it)', base, round: runs.plan + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }));
|
|
274
323
|
const result = parseDevResult(r.output);
|
|
275
324
|
end(rec, result.status === 'blocked' ? 'blocked' : 'done', r.output, log);
|
|
276
325
|
if (result.status === 'blocked')
|
|
277
326
|
throw new Park(`The plan agent stopped: ${result.reason}`);
|
|
327
|
+
await handOff(def, rec, r.output);
|
|
278
328
|
plan = planBody(r.output);
|
|
279
329
|
planToPost = plan;
|
|
280
330
|
runs.plan++;
|
|
@@ -292,14 +342,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
292
342
|
const rec = begin(def);
|
|
293
343
|
const log = logFile(def.builtin ? 'dev' : def.id);
|
|
294
344
|
const before = headSha(worktree);
|
|
295
|
-
const dev = await
|
|
296
|
-
engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
|
|
297
|
-
prompt: buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round: runs.dev + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
298
|
-
});
|
|
345
|
+
const dev = await agent(def, rec, log, buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round: runs.dev + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }));
|
|
299
346
|
const result = parseDevResult(dev.output);
|
|
300
347
|
end(rec, result.status === 'blocked' ? 'blocked' : 'done', dev.output, log);
|
|
301
348
|
if (result.status === 'blocked')
|
|
302
349
|
throw new Park(`The dev agent stopped: ${result.reason}`);
|
|
350
|
+
await handOff(def, rec, dev.output);
|
|
303
351
|
if (commitsAhead(worktree, base) === 0)
|
|
304
352
|
throw new Park('The dev agent made no commits.');
|
|
305
353
|
// A round sent back with findings must change something, or QA would
|
|
@@ -321,13 +369,9 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
321
369
|
await stage(stageOf(def), loops || undefined);
|
|
322
370
|
const rec = begin(def);
|
|
323
371
|
const log = logFile(def.id);
|
|
324
|
-
const r = await
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
});
|
|
328
|
-
end(rec, r.ok ? 'done' : 'failed', r.output, log);
|
|
329
|
-
if (!r.ok)
|
|
330
|
-
throw new Park(`The ${def.label} step failed or timed out.`);
|
|
372
|
+
const r = await agent(def, rec, log, buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none)', base, round: loops || 1, extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }));
|
|
373
|
+
end(rec, 'done', r.output, log, !!def.command);
|
|
374
|
+
await handOff(def, rec, r.output);
|
|
331
375
|
}
|
|
332
376
|
findings.delete(i);
|
|
333
377
|
i++;
|
|
@@ -342,16 +386,19 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
342
386
|
if (writeBack.comment !== false) {
|
|
343
387
|
await linear.comment(card, `**All checks passed.**${prUrl ? ` PR ready: ${prUrl}` : ''}${!moved ? `\n\n(No "${state}" state on this team, so the card was not moved.)` : ''}`);
|
|
344
388
|
}
|
|
345
|
-
say('verified');
|
|
346
|
-
done('verified');
|
|
389
|
+
say(cronRun ? 'done' : 'verified');
|
|
390
|
+
done(cronRun ? 'done' : 'verified');
|
|
347
391
|
}
|
|
348
392
|
catch (err) {
|
|
349
393
|
if (err instanceof Park)
|
|
350
|
-
return park(err.message);
|
|
394
|
+
return park(err.message, err.kind);
|
|
351
395
|
return park(`Something went wrong: ${err.message}`, 'failed');
|
|
352
396
|
}
|
|
353
397
|
finally {
|
|
354
398
|
active = null;
|
|
399
|
+
// A scheduled run that opened no pull request leaves no worktree or branch behind.
|
|
400
|
+
if (cronRun && worktreeMade && !record.prUrl)
|
|
401
|
+
removeWorktree(project.path, worktreeMade, card.branchName);
|
|
355
402
|
// Share whatever notes the agents wrote, whatever the outcome.
|
|
356
403
|
const shared = syncMemory(rc.selfCommand, rc.configDir);
|
|
357
404
|
if (shared)
|
|
@@ -359,7 +406,7 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
359
406
|
}
|
|
360
407
|
// Outside the try, so a ping that throws can never park a verified run.
|
|
361
408
|
await ping(record.prUrl ? 'ready' : 'passed');
|
|
362
|
-
return 'verified';
|
|
409
|
+
return cronRun ? 'done' : 'verified';
|
|
363
410
|
}
|
|
364
411
|
// The workspace whose queue a job waits in. A workflow whose project is
|
|
365
412
|
// missing still queues somewhere, so its run can say what is wrong.
|
|
@@ -379,6 +426,8 @@ export function cronCard(workflow, task, at) {
|
|
|
379
426
|
labels: [],
|
|
380
427
|
};
|
|
381
428
|
}
|
|
429
|
+
// How long a handed card may wait for its run to start.
|
|
430
|
+
const HANDOFF_MAX_MS = 24 * 60 * 60_000;
|
|
382
431
|
const cronKey = (workflow, schedule) => `${workflow} ${schedule}`;
|
|
383
432
|
// Jobs waiting on the workflows' triggers, in workflow order, at most one per
|
|
384
433
|
// workspace and none in a busy one: a card carrying a trigger label, or a
|
|
@@ -415,7 +464,64 @@ export async function findJobs(rc, q = {}) {
|
|
|
415
464
|
out.push({ ...job, workspace });
|
|
416
465
|
return out.length >= limit;
|
|
417
466
|
};
|
|
467
|
+
// Cards other workflows handed over, in the order they were queued. They
|
|
468
|
+
// need no label. Stale or unknown ones are dropped; one whose tracker cannot
|
|
469
|
+
// be read stays queued. True once the limit is reached.
|
|
470
|
+
const takeHandoffs = async () => {
|
|
471
|
+
const queue = readHandoffs(rc.configDir);
|
|
472
|
+
const keep = [];
|
|
473
|
+
let full = false;
|
|
474
|
+
for (const h of queue) {
|
|
475
|
+
if (q.workflow && h.workflow !== q.workflow) {
|
|
476
|
+
keep.push(h);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (now.getTime() - new Date(h.at).getTime() > HANDOFF_MAX_MS) {
|
|
480
|
+
rc.log(`Dropped the handoff of ${h.card} to ${h.workflow}: it waited more than 24 hours.`);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const workflow = rc.config.workflows[h.workflow];
|
|
484
|
+
if (!workflow) {
|
|
485
|
+
rc.log(`Dropped the handoff of ${h.card} to ${h.workflow}: no such workflow.`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const team = h.card.split('-')[0].toUpperCase();
|
|
489
|
+
const triggers = workflow.triggers.filter(isCardTrigger).filter((t) => t.team === team);
|
|
490
|
+
if (!triggers.length) {
|
|
491
|
+
rc.log(`Dropped the handoff of ${h.card} to ${h.workflow}: it has no trigger for team ${team}.`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
keep.push(h);
|
|
495
|
+
if (full)
|
|
496
|
+
continue;
|
|
497
|
+
let found = null;
|
|
498
|
+
let failure = '';
|
|
499
|
+
for (const trigger of triggers) {
|
|
500
|
+
try {
|
|
501
|
+
found = { card: await rc.trackers(trigger.source).card(h.card), trigger };
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
failure = err.message;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (!found) {
|
|
509
|
+
rc.log(`Could not read ${h.card} for its handoff (${failure}). Trying again next minute.`);
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const workspace = jobWorkspace(rc.config, workflow, found.card);
|
|
513
|
+
if (taken.has(workspace) || q.skip?.has(h.card) || out.some((j) => j.card.identifier === h.card))
|
|
514
|
+
continue;
|
|
515
|
+
if (take({ card: found.card, trigger: found.trigger, workflow: h.workflow }, workspace))
|
|
516
|
+
full = true;
|
|
517
|
+
}
|
|
518
|
+
if (keep.length !== queue.length)
|
|
519
|
+
writeHandoffs(rc.configDir, keep);
|
|
520
|
+
return full;
|
|
521
|
+
};
|
|
418
522
|
try {
|
|
523
|
+
if (!q.only && !q.schedule && (await takeHandoffs()))
|
|
524
|
+
return out;
|
|
419
525
|
for (const [name, workflow] of workflows) {
|
|
420
526
|
for (const trigger of workflow.triggers) {
|
|
421
527
|
if (!isCardTrigger(trigger)) {
|
|
@@ -538,6 +644,22 @@ function queueCancel(configDir, cardId, comment, source = 'linear') {
|
|
|
538
644
|
// nothing more to do
|
|
539
645
|
}
|
|
540
646
|
}
|
|
647
|
+
// The card runs that take a slot under the cap. A lock with no card is a
|
|
648
|
+
// check, not a run.
|
|
649
|
+
const cardRuns = (configDir) => liveRuns(configDir).filter((c) => c.card && c.pid !== process.pid);
|
|
650
|
+
// Why no new run may start now: the 5 hour window is held, or the cap is
|
|
651
|
+
// reached. Null when one may.
|
|
652
|
+
export function gateReason(rc, now = new Date()) {
|
|
653
|
+
const w = readWindow(rc.configDir);
|
|
654
|
+
const until = windowHold(w, rc.config.pauseAt ?? DEFAULTS.pauseAt, now);
|
|
655
|
+
if (until)
|
|
656
|
+
return `The 5 hour window is at ${Math.round(w.utilization)}%, so no new run starts until ${clock(until)}. Running cards finish.`;
|
|
657
|
+
const max = rc.config.maxRuns ?? 0;
|
|
658
|
+
const running = max > 0 ? cardRuns(rc.configDir).length : 0;
|
|
659
|
+
if (max > 0 && running >= max)
|
|
660
|
+
return `${running} of ${max} runs are going, so no new one starts until one finishes.`;
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
541
663
|
// Runs one job in its workspace's queue: a pinned card, a workflow's schedule,
|
|
542
664
|
// or the first job waiting in a free workspace. The workspace lock keeps two
|
|
543
665
|
// runs from working in one workspace at once. `child` is a run the dispatcher
|
|
@@ -585,6 +707,12 @@ export async function tick(rc, only, opts = {}) {
|
|
|
585
707
|
await flushPending(rc);
|
|
586
708
|
if (!only && isPaused(rc.configDir))
|
|
587
709
|
return 0;
|
|
710
|
+
// A named card goes ahead of the cap and the hold, as it does a pause.
|
|
711
|
+
const gate = !only && !opts.child ? gateReason(rc) : null;
|
|
712
|
+
if (gate) {
|
|
713
|
+
rc.log(gate);
|
|
714
|
+
return 0;
|
|
715
|
+
}
|
|
588
716
|
let job;
|
|
589
717
|
try {
|
|
590
718
|
const busy = only ? undefined : new Set(liveRuns(rc.configDir).filter((c) => c.pid !== process.pid).map((c) => workspaceOf(c)));
|
|
@@ -615,8 +743,11 @@ export async function tick(rc, only, opts = {}) {
|
|
|
615
743
|
return 1;
|
|
616
744
|
}
|
|
617
745
|
held = job.workspace;
|
|
746
|
+
// A handed card leaves the queue only once its run has started.
|
|
747
|
+
if (isCardTrigger(job.trigger))
|
|
748
|
+
takeHandoff(rc.configDir, job.card.identifier, job.workflow);
|
|
618
749
|
const outcome = await runCard(job.card, job.trigger, job.workflow, workflow, resolved.project, rc, { from: opts.from });
|
|
619
|
-
return outcome === 'verified' ? 0 : 1;
|
|
750
|
+
return outcome === 'verified' || outcome === 'done' ? 0 : 1;
|
|
620
751
|
}
|
|
621
752
|
catch (err) {
|
|
622
753
|
rc.log(`Error: ${err.message}`);
|
|
@@ -637,6 +768,7 @@ const realDeps = (rc) => ({
|
|
|
637
768
|
return spawn(bin, [...rest, 'agents', 'tick', ...args], { stdio: 'inherit' });
|
|
638
769
|
},
|
|
639
770
|
waitMs: 60_000,
|
|
771
|
+
window: () => refreshWindow(rc.configDir),
|
|
640
772
|
});
|
|
641
773
|
// The background runner. The service starts a new pass only once the last one
|
|
642
774
|
// ended, so this pass stays up while cards run: it starts one child process
|
|
@@ -670,12 +802,50 @@ export async function dispatch(rc, over = {}) {
|
|
|
670
802
|
};
|
|
671
803
|
process.on('SIGTERM', onSignal);
|
|
672
804
|
process.on('SIGINT', onSignal);
|
|
805
|
+
// The window is read once at the start when the last reading is old, then
|
|
806
|
+
// every 5 minutes while runs go on, one read at a time.
|
|
807
|
+
let reading = false;
|
|
808
|
+
const readWindowNow = async () => {
|
|
809
|
+
if (reading)
|
|
810
|
+
return;
|
|
811
|
+
reading = true;
|
|
812
|
+
try {
|
|
813
|
+
await deps.window();
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
// window.json keeps the last reading
|
|
817
|
+
}
|
|
818
|
+
finally {
|
|
819
|
+
reading = false;
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
let timer;
|
|
823
|
+
let wasHeld = false;
|
|
673
824
|
try {
|
|
825
|
+
if (windowStale(readWindow(rc.configDir)))
|
|
826
|
+
await readWindowNow();
|
|
827
|
+
timer = setInterval(() => void readWindowNow(), WINDOW_MS);
|
|
828
|
+
timer.unref();
|
|
674
829
|
for (;;) {
|
|
830
|
+
// Runs whose process ended without an outcome are closed as failed.
|
|
831
|
+
try {
|
|
832
|
+
for (const card of closeStale(rc.configDir, liveRuns(rc.configDir)))
|
|
833
|
+
rc.log(`${card} failed: the run stopped without finishing.`);
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
// looked at again next pass
|
|
837
|
+
}
|
|
675
838
|
if (deps.reload)
|
|
676
839
|
rc = { ...rc, config: deps.reload() };
|
|
677
840
|
await flushPending(rc);
|
|
678
841
|
const paused = isPaused(rc.configDir);
|
|
842
|
+
const w = readWindow(rc.configDir);
|
|
843
|
+
const heldUntil = windowHold(w, rc.config.pauseAt ?? DEFAULTS.pauseAt, new Date());
|
|
844
|
+
if (heldUntil && !wasHeld)
|
|
845
|
+
rc.log(`The 5 hour window is at ${Math.round(w.utilization)}%, so no new run starts until ${clock(heldUntil)}. Running cards finish.`);
|
|
846
|
+
if (!heldUntil && wasHeld)
|
|
847
|
+
rc.log('The 5 hour window reset, so new runs start again.');
|
|
848
|
+
wasHeld = !!heldUntil;
|
|
679
849
|
if (rc.summary) {
|
|
680
850
|
try {
|
|
681
851
|
await rc.summary(paused);
|
|
@@ -684,10 +854,14 @@ export async function dispatch(rc, over = {}) {
|
|
|
684
854
|
rc.log(`discord: ${err.message}`);
|
|
685
855
|
}
|
|
686
856
|
}
|
|
687
|
-
|
|
857
|
+
// Only card runs take a slot under the cap.
|
|
858
|
+
const running = new Set([...children.keys(), ...cardRuns(rc.configDir).map((c) => workspaceOf(c))]);
|
|
859
|
+
const max = rc.config.maxRuns ?? 0;
|
|
860
|
+
const limit = max > 0 ? max - running.size : Infinity;
|
|
861
|
+
if (!paused && !heldUntil && limit > 0) {
|
|
688
862
|
const busy = new Set([...children.keys(), ...liveRuns(rc.configDir).map((c) => workspaceOf(c))]);
|
|
689
863
|
const skip = new Set([...started].filter(([, at]) => Date.now() - at < deps.waitMs).map(([id]) => id));
|
|
690
|
-
for (const job of await findJobs(rc, { busy, skip })) {
|
|
864
|
+
for (const job of await findJobs(rc, { busy, skip, limit })) {
|
|
691
865
|
started.set(job.card.identifier, Date.now());
|
|
692
866
|
rc.log(`${job.card.identifier} starts ${job.workflow} in workspace ${job.workspace}.`);
|
|
693
867
|
const child = deps.start(jobArgs(job));
|
|
@@ -722,6 +896,7 @@ export async function dispatch(rc, over = {}) {
|
|
|
722
896
|
return 1;
|
|
723
897
|
}
|
|
724
898
|
finally {
|
|
899
|
+
clearInterval(timer);
|
|
725
900
|
process.off('SIGTERM', onSignal);
|
|
726
901
|
process.off('SIGINT', onSignal);
|
|
727
902
|
clearCurrent(rc.configDir);
|
package/dist/service.js
CHANGED
|
@@ -13,6 +13,6 @@ const linux = {
|
|
|
13
13
|
start: (home, program, logPath) => systemd.startRunner(home, program, launchd.runnerEnv(), logPath),
|
|
14
14
|
stop: systemd.stopRunner,
|
|
15
15
|
programArgs: systemd.programArgs,
|
|
16
|
-
checks: () => [systemd.lingerCheck()],
|
|
16
|
+
checks: () => [systemd.lingerCheck(), systemd.sudoCheck()],
|
|
17
17
|
};
|
|
18
18
|
export const runnerService = (platform = process.platform) => (platform === 'darwin' ? mac : linux);
|
package/dist/state.d.ts
CHANGED
|
@@ -29,6 +29,14 @@ export declare const liveRuns: (configDir: string) => Current[];
|
|
|
29
29
|
export declare const clearLock: (configDir: string, c: Current) => void;
|
|
30
30
|
export declare const isPaused: (configDir: string) => boolean;
|
|
31
31
|
export declare function setPaused(configDir: string, paused: boolean): void;
|
|
32
|
+
export interface Window {
|
|
33
|
+
checkedAt: string;
|
|
34
|
+
utilization?: number;
|
|
35
|
+
resetsAt?: string;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function readWindow(configDir: string): Window | null;
|
|
39
|
+
export declare function writeWindow(configDir: string, w: Window): void;
|
|
32
40
|
export interface Pending {
|
|
33
41
|
card: string;
|
|
34
42
|
comment: string;
|
|
@@ -39,7 +47,19 @@ export interface Pending {
|
|
|
39
47
|
}
|
|
40
48
|
export declare function readPending(configDir: string): Pending[];
|
|
41
49
|
export declare function writePending(configDir: string, pending: Pending[]): void;
|
|
42
|
-
export type Outcome = 'running' | 'verified' | 'changes' | 'parked' | 'cancelled';
|
|
50
|
+
export type Outcome = 'running' | 'verified' | 'done' | 'changes' | 'parked' | 'failed' | 'cancelled';
|
|
51
|
+
export interface Usage {
|
|
52
|
+
input?: number;
|
|
53
|
+
output?: number;
|
|
54
|
+
cached?: number;
|
|
55
|
+
costUsd?: number;
|
|
56
|
+
model?: string;
|
|
57
|
+
}
|
|
58
|
+
export type RunUsage = Omit<Usage, 'model'> & {
|
|
59
|
+
models?: string[];
|
|
60
|
+
};
|
|
61
|
+
export declare function addUsage(total: RunUsage | undefined, step: Usage): RunUsage;
|
|
62
|
+
export declare function usageText(u: Usage | RunUsage | undefined): string;
|
|
43
63
|
export interface StepRecord {
|
|
44
64
|
step: string;
|
|
45
65
|
label: string;
|
|
@@ -48,6 +68,8 @@ export interface StepRecord {
|
|
|
48
68
|
result?: string;
|
|
49
69
|
summary?: string;
|
|
50
70
|
log?: string;
|
|
71
|
+
usage?: Usage;
|
|
72
|
+
handoffs?: string[];
|
|
51
73
|
}
|
|
52
74
|
export interface RunRecord {
|
|
53
75
|
card: string;
|
|
@@ -63,6 +85,7 @@ export interface RunRecord {
|
|
|
63
85
|
workspace?: string;
|
|
64
86
|
startedAt: string;
|
|
65
87
|
endedAt?: string;
|
|
88
|
+
usage?: RunUsage;
|
|
66
89
|
}
|
|
67
90
|
export declare const runsDir: (configDir: string) => string;
|
|
68
91
|
export declare function writeRun(runDir: string, run: RunRecord): void;
|
|
@@ -76,3 +99,19 @@ export interface RunEntry {
|
|
|
76
99
|
export declare function listRuns(configDir: string, card?: string): RunEntry[];
|
|
77
100
|
export declare function readCronState(configDir: string): Record<string, string>;
|
|
78
101
|
export declare function writeCronState(configDir: string, state: Record<string, string>): void;
|
|
102
|
+
export interface Handoff {
|
|
103
|
+
card: string;
|
|
104
|
+
workflow: string;
|
|
105
|
+
from: string;
|
|
106
|
+
at: string;
|
|
107
|
+
}
|
|
108
|
+
export declare function readHandoffs(configDir: string): Handoff[];
|
|
109
|
+
export declare function writeHandoffs(configDir: string, handoffs: Handoff[]): void;
|
|
110
|
+
export declare function queueHandoffs(configDir: string, workflow: string, cards: string[], from: string, now?: Date): Handoff[];
|
|
111
|
+
export declare function takeHandoff(configDir: string, card: string, workflow: string): void;
|
|
112
|
+
export declare function lastSeen(run: RunRecord): string;
|
|
113
|
+
export declare const STOPPED_REASON = "The run stopped without finishing: its process ended before it could record an outcome.";
|
|
114
|
+
export declare function closeStale(configDir: string, live: {
|
|
115
|
+
runDir?: string;
|
|
116
|
+
card?: string;
|
|
117
|
+
}[]): string[];
|