@voidlabs.gg/voidshift-agents 0.6.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/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();
@@ -111,8 +124,20 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
111
124
  await linear.comment(card, `${PLAN_HEADING}\n\n${planToPost}`);
112
125
  planToPost = null;
113
126
  };
114
- async function park(reason) {
115
- say(`parked: ${reason}`);
127
+ const ping = async (kind, reason) => {
128
+ if (!rc.notify)
129
+ return;
130
+ try {
131
+ await rc.notify({ kind, card: card.identifier, title: card.title, workflow: workflowName, url: card.url || undefined, prUrl: record.prUrl, reason });
132
+ }
133
+ catch (err) {
134
+ say(`discord: ${err.message}`);
135
+ }
136
+ };
137
+ async function park(reason, kind = 'parked') {
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);
116
141
  const comment = `**Parked:** ${reason}${lastFindings ? `\n\n${lastFindings}` : ''}\n\nFix the cause, then add \`${label}\` again to retry.`;
117
142
  const add = labelsOn ? [LABELS.parked, ...(loops ? [LABELS.version(loops)] : [])] : [];
118
143
  let commented = false;
@@ -123,14 +148,17 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
123
148
  await endLabels(LABELS.parked, loops || undefined);
124
149
  }
125
150
  catch (err) {
126
- if (!isCardTrigger(trigger))
127
- return done('parked', reason);
128
- say(`could not reach ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} to park it (${err.message}). Will retry next minute.`);
129
- // An empty comment is skipped on retry, so it is not posted twice.
130
- writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [label], add, dropStatus: labelsOn, source: trigger.source }]);
151
+ if (isCardTrigger(trigger)) {
152
+ say(`could not reach ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} to park it (${err.message}). Will retry next minute.`);
153
+ // An empty comment is skipped on retry, so it is not posted twice.
154
+ writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [label], add, dropStatus: labelsOn, source: trigger.source }]);
155
+ }
131
156
  }
132
- return done('parked', reason);
157
+ await ping(kind, reason);
158
+ return outcome;
133
159
  }
160
+ // Set once a worktree exists, so a scheduled run can remove it at the end.
161
+ let worktreeMade = '';
134
162
  try {
135
163
  if (!existsSync(join(project.path, '.git')))
136
164
  throw new Park(`The project folder ${project.path} is not a git repository on this Mac.`);
@@ -138,6 +166,8 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
138
166
  const from = opts.from ? steps.findIndex((d) => d.id === opts.from) : 0;
139
167
  if (from < 0)
140
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);
141
171
  // A scheduled run has no card to plan on, so its task is the plan unless a Plan step writes one.
142
172
  let plan = isCardTrigger(trigger) ? latestPlan(await linear.comments(card.identifier)) : card.description ?? null;
143
173
  if (!plan && needsPlan(steps, from)) {
@@ -161,15 +191,53 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
161
191
  say(pulled);
162
192
  const context = workspaceContext(config, workspace);
163
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');
164
- const worktree = join(agentsDir(rc.configDir), 'worktrees', card.identifier);
165
- ensureWorktree(project.path, worktree, card.branchName, base);
166
- 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
+ }
167
200
  const findings = new Map();
168
201
  const runs = { plan: 0, dev: 0 };
169
202
  let prUrl = '';
170
203
  let logs = 0;
171
204
  const planText = () => plan ?? '(No plan was posted on this card. Check the work against the card itself.)';
172
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
+ }
173
241
  async function check(def, round) {
174
242
  const label = def.label;
175
243
  const rec = begin(def);
@@ -203,14 +271,10 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
203
271
  }
204
272
  }
205
273
  const log = logFile(def.id);
206
- const r = await runAgent({
207
- engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
208
- prompt: buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
209
- });
210
- if (def.builtin === 'vs-qa')
211
- 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') }));
212
275
  const verdict = parseVerdict(r.output);
213
276
  end(rec, verdict, r.output, log);
277
+ await handOff(def, rec, r.output);
214
278
  return { step: label, verdict, report: r.output };
215
279
  }
216
280
  let i = from;
@@ -255,14 +319,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
255
319
  await stage(stageOf(def), runs.plan + 1);
256
320
  const rec = begin(def);
257
321
  const log = logFile(def.builtin ? 'plan' : def.id);
258
- const r = await runAgent({
259
- engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
260
- 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') }),
261
- });
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') }));
262
323
  const result = parseDevResult(r.output);
263
324
  end(rec, result.status === 'blocked' ? 'blocked' : 'done', r.output, log);
264
325
  if (result.status === 'blocked')
265
326
  throw new Park(`The plan agent stopped: ${result.reason}`);
327
+ await handOff(def, rec, r.output);
266
328
  plan = planBody(r.output);
267
329
  planToPost = plan;
268
330
  runs.plan++;
@@ -280,14 +342,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
280
342
  const rec = begin(def);
281
343
  const log = logFile(def.builtin ? 'dev' : def.id);
282
344
  const before = headSha(worktree);
283
- const dev = await runAgent({
284
- engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
285
- 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') }),
286
- });
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') }));
287
346
  const result = parseDevResult(dev.output);
288
347
  end(rec, result.status === 'blocked' ? 'blocked' : 'done', dev.output, log);
289
348
  if (result.status === 'blocked')
290
349
  throw new Park(`The dev agent stopped: ${result.reason}`);
350
+ await handOff(def, rec, dev.output);
291
351
  if (commitsAhead(worktree, base) === 0)
292
352
  throw new Park('The dev agent made no commits.');
293
353
  // A round sent back with findings must change something, or QA would
@@ -309,13 +369,9 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
309
369
  await stage(stageOf(def), loops || undefined);
310
370
  const rec = begin(def);
311
371
  const log = logFile(def.id);
312
- const r = await runAgent({
313
- engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
314
- prompt: buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none)', base, round: loops || 1, extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
315
- });
316
- end(rec, r.ok ? 'done' : 'failed', r.output, log);
317
- if (!r.ok)
318
- 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);
319
375
  }
320
376
  findings.delete(i);
321
377
  i++;
@@ -330,21 +386,27 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
330
386
  if (writeBack.comment !== false) {
331
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.)` : ''}`);
332
388
  }
333
- say('verified');
334
- return done('verified');
389
+ say(cronRun ? 'done' : 'verified');
390
+ done(cronRun ? 'done' : 'verified');
335
391
  }
336
392
  catch (err) {
337
393
  if (err instanceof Park)
338
- return park(err.message);
339
- return park(`Something went wrong: ${err.message}`);
394
+ return park(err.message, err.kind);
395
+ return park(`Something went wrong: ${err.message}`, 'failed');
340
396
  }
341
397
  finally {
342
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);
343
402
  // Share whatever notes the agents wrote, whatever the outcome.
344
403
  const shared = syncMemory(rc.selfCommand, rc.configDir);
345
404
  if (shared)
346
405
  say(shared);
347
406
  }
407
+ // Outside the try, so a ping that throws can never park a verified run.
408
+ await ping(record.prUrl ? 'ready' : 'passed');
409
+ return cronRun ? 'done' : 'verified';
348
410
  }
349
411
  // The workspace whose queue a job waits in. A workflow whose project is
350
412
  // missing still queues somewhere, so its run can say what is wrong.
@@ -364,6 +426,8 @@ export function cronCard(workflow, task, at) {
364
426
  labels: [],
365
427
  };
366
428
  }
429
+ // How long a handed card may wait for its run to start.
430
+ const HANDOFF_MAX_MS = 24 * 60 * 60_000;
367
431
  const cronKey = (workflow, schedule) => `${workflow} ${schedule}`;
368
432
  // Jobs waiting on the workflows' triggers, in workflow order, at most one per
369
433
  // workspace and none in a busy one: a card carrying a trigger label, or a
@@ -400,7 +464,64 @@ export async function findJobs(rc, q = {}) {
400
464
  out.push({ ...job, workspace });
401
465
  return out.length >= limit;
402
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
+ };
403
522
  try {
523
+ if (!q.only && !q.schedule && (await takeHandoffs()))
524
+ return out;
404
525
  for (const [name, workflow] of workflows) {
405
526
  for (const trigger of workflow.triggers) {
406
527
  if (!isCardTrigger(trigger)) {
@@ -523,6 +644,22 @@ function queueCancel(configDir, cardId, comment, source = 'linear') {
523
644
  // nothing more to do
524
645
  }
525
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
+ }
526
663
  // Runs one job in its workspace's queue: a pinned card, a workflow's schedule,
527
664
  // or the first job waiting in a free workspace. The workspace lock keeps two
528
665
  // runs from working in one workspace at once. `child` is a run the dispatcher
@@ -570,6 +707,12 @@ export async function tick(rc, only, opts = {}) {
570
707
  await flushPending(rc);
571
708
  if (!only && isPaused(rc.configDir))
572
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
+ }
573
716
  let job;
574
717
  try {
575
718
  const busy = only ? undefined : new Set(liveRuns(rc.configDir).filter((c) => c.pid !== process.pid).map((c) => workspaceOf(c)));
@@ -600,8 +743,11 @@ export async function tick(rc, only, opts = {}) {
600
743
  return 1;
601
744
  }
602
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);
603
749
  const outcome = await runCard(job.card, job.trigger, job.workflow, workflow, resolved.project, rc, { from: opts.from });
604
- return outcome === 'verified' ? 0 : 1;
750
+ return outcome === 'verified' || outcome === 'done' ? 0 : 1;
605
751
  }
606
752
  catch (err) {
607
753
  rc.log(`Error: ${err.message}`);
@@ -622,6 +768,7 @@ const realDeps = (rc) => ({
622
768
  return spawn(bin, [...rest, 'agents', 'tick', ...args], { stdio: 'inherit' });
623
769
  },
624
770
  waitMs: 60_000,
771
+ window: () => refreshWindow(rc.configDir),
625
772
  });
626
773
  // The background runner. The service starts a new pass only once the last one
627
774
  // ended, so this pass stays up while cards run: it starts one child process
@@ -655,15 +802,66 @@ export async function dispatch(rc, over = {}) {
655
802
  };
656
803
  process.on('SIGTERM', onSignal);
657
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;
658
824
  try {
825
+ if (windowStale(readWindow(rc.configDir)))
826
+ await readWindowNow();
827
+ timer = setInterval(() => void readWindowNow(), WINDOW_MS);
828
+ timer.unref();
659
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
+ }
660
838
  if (deps.reload)
661
839
  rc = { ...rc, config: deps.reload() };
662
840
  await flushPending(rc);
663
- if (!isPaused(rc.configDir)) {
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;
849
+ if (rc.summary) {
850
+ try {
851
+ await rc.summary(paused);
852
+ }
853
+ catch (err) {
854
+ rc.log(`discord: ${err.message}`);
855
+ }
856
+ }
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) {
664
862
  const busy = new Set([...children.keys(), ...liveRuns(rc.configDir).map((c) => workspaceOf(c))]);
665
863
  const skip = new Set([...started].filter(([, at]) => Date.now() - at < deps.waitMs).map(([id]) => id));
666
- for (const job of await findJobs(rc, { busy, skip })) {
864
+ for (const job of await findJobs(rc, { busy, skip, limit })) {
667
865
  started.set(job.card.identifier, Date.now());
668
866
  rc.log(`${job.card.identifier} starts ${job.workflow} in workspace ${job.workspace}.`);
669
867
  const child = deps.start(jobArgs(job));
@@ -698,6 +896,7 @@ export async function dispatch(rc, over = {}) {
698
896
  return 1;
699
897
  }
700
898
  finally {
899
+ clearInterval(timer);
701
900
  process.off('SIGTERM', onSignal);
702
901
  process.off('SIGINT', onSignal);
703
902
  clearCurrent(rc.configDir);
package/dist/secrets.d.ts CHANGED
@@ -5,5 +5,6 @@ export declare function readSecretsFile(path: string): Record<string, string>;
5
5
  export declare function writeSecretsFile(path: string, values: Record<string, string>): void;
6
6
  export declare function readSecret(configDir: string, name: string, platform?: NodeJS.Platform): string | null;
7
7
  export declare function storeSecret(configDir: string, name: string, value: string, platform?: NodeJS.Platform): boolean;
8
+ export declare function removeSecret(configDir: string, name: string, platform?: NodeJS.Platform): boolean;
8
9
  export declare const readLinearKey: (configDir: string) => string | null;
9
10
  export declare const storeLinearKey: (configDir: string, key: string) => boolean;
package/dist/secrets.js CHANGED
@@ -40,7 +40,7 @@ export function readSecret(configDir, name, platform = process.platform) {
40
40
  return readSecretsFile(secretsPath(configDir))[name] || null;
41
41
  }
42
42
  export function storeSecret(configDir, name, value, platform = process.platform) {
43
- if (!SAFE_VALUE.test(value) || !/^[a-z-]+$/.test(name))
43
+ if (!SAFE_VALUE.test(value) || !/^[a-z0-9-]+$/.test(name))
44
44
  return false;
45
45
  if (platform === 'darwin') {
46
46
  // On stdin through `security -i`, so the value never appears in a process list.
@@ -51,5 +51,16 @@ export function storeSecret(configDir, name, value, platform = process.platform)
51
51
  writeSecretsFile(path, { ...readSecretsFile(path), [name]: value });
52
52
  return true;
53
53
  }
54
+ export function removeSecret(configDir, name, platform = process.platform) {
55
+ if (platform === 'darwin') {
56
+ // 44 is the Keychain's "not found": already gone counts as removed.
57
+ const status = spawnSync('/usr/bin/security', ['delete-generic-password', '-s', service(name)], { encoding: 'utf8' }).status;
58
+ return status === 0 || status === 44;
59
+ }
60
+ const path = secretsPath(configDir);
61
+ const { [name]: _gone, ...rest } = readSecretsFile(path);
62
+ writeSecretsFile(path, rest);
63
+ return true;
64
+ }
54
65
  export const readLinearKey = (configDir) => readSecret(configDir, 'linear');
55
66
  export const storeLinearKey = (configDir, key) => looksLikeLinearKey(key) && storeSecret(configDir, 'linear', key);
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[];