@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/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
- const sources = [step.skill, step.prompt].filter((v) => v !== undefined).length;
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 prompt.`);
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 prompt.`);
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(`parked: ${reason}`);
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
- ensureWorktree(project.path, worktree, card.branchName, base);
178
- say(`working in ${worktree} on ${card.branchName}`);
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 runAgent({
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 runAgent({
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 runAgent({
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 runAgent({
325
- engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
326
- prompt: buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none)', base, round: loops || 1, extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
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
- if (!paused) {
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[];