@voidlabs.gg/voidshift-agents 0.5.0 → 0.6.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 CHANGED
@@ -74,9 +74,9 @@ or `vs-parked`) and the round (`vs-v1`, `vs-v2`). On Linear it posts the plan an
74
74
  one final comment, nothing in between.
75
75
 
76
76
  ```sh
77
- vsf agents status # runner, the card in progress, parked cards
77
+ vsf agents status # runner, the card each workspace is on, parked cards
78
78
  vsf agents run ENG-123 [--from qa] # run one card now, in this terminal
79
- vsf agents start | stop | restart # the background runner (stop cancels the card)
79
+ vsf agents start | stop | restart # the background runner (stop cancels its cards)
80
80
  vsf agents pause | resume # pick up no new cards for a while
81
81
  vsf agents cancel | retry ENG-123 # stop a card, or queue a parked one again
82
82
  vsf agents runs | show ENG-123 # what happened, step by step
@@ -84,11 +84,25 @@ vsf agents logs [ENG-123] [-f] # the runner log, or a card's step logs
84
84
  vsf agents workflows # workflows in the order they match
85
85
  vsf agents workflow add|edit|show|rename|move|rm
86
86
  vsf agents projects | project add|edit|show|rename|rm
87
+ vsf agents workspace add|list|rm # folders of repos, each with its own queue
87
88
  vsf agents homes | link DIR # where the agents are installed
88
89
  vsf agents clean # old worktrees and run logs
89
90
  vsf agents validate [DIR] # check the config and agent folders
90
91
  ```
91
92
 
93
+ Each workspace has one queue: cards in different workspaces run side by side,
94
+ two cards in one workspace one after the other. Projects are in the `default`
95
+ workspace until you move them:
96
+
97
+ ```sh
98
+ vsf agents workspace add solvia --root ~/work/solvia # steps carry ~/work/solvia/CONTEXT.md
99
+ vsf agents project edit erp --workspace solvia --match DS06
100
+ ```
101
+
102
+ `--match` names a tracker project (or its first word, so `DS06` matches "DS06
103
+ Atleurope ERP"). A card of that project runs in the matching project, in any
104
+ workspace, before the workflow's own project is considered.
105
+
92
106
  A workflow can also be a folder you write by hand,
93
107
  `~/.config/voidshift/agents/defs/NAME/agent.json`, with its prompt files next to
94
108
  it. Steps are the built-in ids above or your own:
package/dist/config.d.ts CHANGED
@@ -52,7 +52,16 @@ export declare function describeTrigger(t: Trigger): string;
52
52
  export interface Project {
53
53
  path: string;
54
54
  base?: string;
55
+ workspace?: string;
56
+ match?: string[];
55
57
  }
58
+ export interface Workspace {
59
+ root: string;
60
+ context?: string;
61
+ }
62
+ export declare const DEFAULT_WORKSPACE = "default";
63
+ export declare const WORKSPACE_NAME: RegExp;
64
+ export declare const workspaceOf: (project: Pick<Project, "workspace"> | undefined) => string;
56
65
  export interface WriteBack {
57
66
  labels?: boolean;
58
67
  state?: string | null;
@@ -72,6 +81,7 @@ export interface AgentsConfig {
72
81
  review?: string;
73
82
  };
74
83
  projects: Record<string, Project>;
84
+ workspaces: Record<string, Workspace>;
75
85
  workflows: Record<string, Workflow>;
76
86
  reviewState: string;
77
87
  timeoutMinutes: number;
@@ -104,6 +114,15 @@ export declare function agentFolders(configDir: string): string[];
104
114
  export declare function readAgentFolder(dir: string): Workflow;
105
115
  export declare function readAgentsConfig(configDir: string): AgentsConfig;
106
116
  export declare function writeAgentsConfig(configDir: string, config: AgentsConfig): void;
117
+ export declare const workspacesInUse: (config: Pick<AgentsConfig, "projects" | "workspaces">) => string[];
118
+ export declare function matchesProject(entry: string, projectName: string): boolean;
119
+ export declare function resolveProject(config: Pick<AgentsConfig, 'projects'>, workflow: Pick<Workflow, 'project'>, card: {
120
+ projectName?: string;
121
+ }): {
122
+ name: string;
123
+ project: Project;
124
+ } | null;
125
+ export declare function workspaceContext(config: Pick<AgentsConfig, 'workspaces'>, name: string): string;
107
126
  export interface ResolvedStep {
108
127
  id: string;
109
128
  label: string;
package/dist/config.js CHANGED
@@ -43,6 +43,9 @@ export function describeTrigger(t) {
43
43
  return `cron ${t.schedule}`;
44
44
  return `${t.source === 'voidtrack' ? 'VoidTrack ' : ''}${t.team} + ${t.label}`;
45
45
  }
46
+ export const DEFAULT_WORKSPACE = 'default';
47
+ export const WORKSPACE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
48
+ export const workspaceOf = (project) => project?.workspace || DEFAULT_WORKSPACE;
46
49
  // Step lists to start a workflow from.
47
50
  export const TEMPLATES = {
48
51
  build: { steps: ['vs-dev', 'vs-qa'], maxRounds: 3 },
@@ -53,6 +56,7 @@ export const DEFAULTS = {
53
56
  engine: 'claude',
54
57
  models: { dev: 'opus', review: 'sonnet' },
55
58
  projects: {},
59
+ workspaces: {},
56
60
  workflows: {},
57
61
  reviewState: 'In Review',
58
62
  timeoutMinutes: 45,
@@ -157,6 +161,45 @@ export function writeAgentsConfig(configDir, config) {
157
161
  const workflows = Object.fromEntries(Object.entries(config.workflows).filter(([name]) => !folders?.[name]));
158
162
  writeFileSync(configPath(configDir), JSON.stringify({ ...rest, workflows }, null, 2) + '\n');
159
163
  }
164
+ // Every workspace a config uses: the named ones, then any a project names,
165
+ // the default one included.
166
+ export const workspacesInUse = (config) => [
167
+ ...new Set([...Object.keys(config.workspaces ?? {}), ...Object.values(config.projects).map(workspaceOf)]),
168
+ ];
169
+ // Whether a match entry names a tracker project: the whole name, or its first
170
+ // word, so "DS06" matches "DS06 Atleurope ERP". Case does not matter.
171
+ export function matchesProject(entry, projectName) {
172
+ const want = entry.trim().toLowerCase();
173
+ const name = projectName.trim().toLowerCase();
174
+ return !!want && (want === name || want === name.split(/\s+/)[0]);
175
+ }
176
+ // The project a card runs in. Explicit matches on the card's tracker project
177
+ // come first, in every workspace, and only then the workflow's own project.
178
+ // A scheduled run has no tracker project, so it always takes the workflow's.
179
+ export function resolveProject(config, workflow, card) {
180
+ if (card.projectName) {
181
+ for (const [name, project] of Object.entries(config.projects)) {
182
+ if (project.match?.some((m) => matchesProject(m, card.projectName)))
183
+ return { name, project };
184
+ }
185
+ }
186
+ const project = config.projects[workflow.project];
187
+ return project ? { name: workflow.project, project } : null;
188
+ }
189
+ // The workspace's context text for the step prompts: its context file, or
190
+ // CONTEXT.md in its root. Empty when there is none.
191
+ export function workspaceContext(config, name) {
192
+ const ws = config.workspaces?.[name];
193
+ if (!ws)
194
+ return '';
195
+ const file = ws.context ? resolve(ws.root, ws.context) : join(ws.root, 'CONTEXT.md');
196
+ try {
197
+ return readFileSync(file, 'utf8').trim();
198
+ }
199
+ catch {
200
+ return '';
201
+ }
202
+ }
160
203
  const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
161
204
  const packSkill = (name) => join(packDir, name, 'SKILL.md');
162
205
  // The file a custom step's prompt comes from: its prompt file, a pack skill,
package/dist/control.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type AgentsConfig, type Tracker as Kind } from './config.ts';
2
2
  import { type Current, type RunEntry } from './state.ts';
3
+ import { type Trackers } from './tracker.ts';
3
4
  import type { Check, ToolCommand, ToolContext } from './types.ts';
4
5
  export { parsePlistArgs } from './launchd.ts';
5
6
  export declare const trackersInUse: (config: Pick<AgentsConfig, "workflows">) => Kind[];
@@ -9,8 +10,8 @@ export declare function stampDate(stamp: string): Date;
9
10
  export declare function countRounds(steps: {
10
11
  result?: string;
11
12
  }[]): number;
12
- export declare const isInterrupted: (e: RunEntry, live: Current | null) => boolean;
13
- export declare function formatRunLine(e: RunEntry, now?: number, live?: Current | null): string;
13
+ export declare const isInterrupted: (e: RunEntry, live: Current[]) => boolean;
14
+ export declare function formatRunLine(e: RunEntry, now?: number, live?: Current[]): string;
14
15
  export declare function scanRunnerErrors(text: string, now: number): {
15
16
  count: number;
16
17
  last: string;
@@ -23,5 +24,16 @@ export interface CancelDeps {
23
24
  cancelCard: (card: string, label: string, source: Kind) => Promise<boolean>;
24
25
  }
25
26
  export declare function cancelCurrent(ctx: ToolContext, c: Current, deps: CancelDeps): Promise<void>;
27
+ export declare function trackerWarnings(kinds: Kind[], trackers: Trackers): Promise<{
28
+ kind: Kind;
29
+ message: string;
30
+ }[]>;
31
+ export declare function workingLine(current: (Current & {
32
+ elapsedMinutes: number;
33
+ }) | null): string;
34
+ export declare function workspaceLines(workspaces: string[], running: (Current & {
35
+ elapsedMinutes: number;
36
+ })[]): string[];
37
+ export declare function pickCancel(locks: Current[], dispatcher: Current | null, card?: string): Current | string;
26
38
  export declare const controlCommands: Record<string, ToolCommand>;
27
39
  export declare function controlChecks(ctx: ToolContext): Promise<Check[]>;
package/dist/control.js CHANGED
@@ -2,13 +2,13 @@ import { spawnSync } from 'node:child_process';
2
2
  import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
- import { agentsDir, isCardTrigger, readAgentsConfig } from "./config.js";
6
- import { isPaused, listRuns, ours, clearCurrent, liveCurrent, readCurrent, setPaused, writeRun } from "./state.js";
5
+ import { agentsDir, isCardTrigger, readAgentsConfig, workspaceOf, workspacesInUse } from "./config.js";
6
+ import { isPaused, listRuns, ours, clearLock, liveCurrent, liveRuns, readCurrent, setPaused, workspaceLocks, writeRun } from "./state.js";
7
7
  import { runnerService } from "./service.js";
8
8
  import { TRACKERS, trackersFor } from "./tracker.js";
9
9
  import { parseFrom, resolveName, stepNames } from "./names.js";
10
10
  import { isStatusLabel } from "./workflow.js";
11
- import { cancelCard, tick } from "./runner.js";
11
+ import { cancelCard, dispatch, tick } from "./runner.js";
12
12
  const HOUR = 3_600_000;
13
13
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
14
14
  const CARD_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
@@ -78,9 +78,9 @@ export function countRounds(steps) {
78
78
  }
79
79
  return rounds;
80
80
  }
81
- // A run that says 'running' but is not the live card was cut off.
82
- export const isInterrupted = (e, live) => e.record?.outcome === 'running' && !(live && (live.runDir ? live.runDir === e.dir : live.card === e.card));
83
- export function formatRunLine(e, now = Date.now(), live = null) {
81
+ // A run that says 'running' but is none of the live runs was cut off.
82
+ export const isInterrupted = (e, live) => e.record?.outcome === 'running' && !live.some((l) => (l.runDir ? l.runDir === e.dir : l.card === e.card));
83
+ export function formatRunLine(e, now = Date.now(), live = []) {
84
84
  const r = e.record;
85
85
  if (!r)
86
86
  return `${e.card} ${when(stampDate(e.stamp))} older run, logs only (${plural(e.logs.length, 'log')})`;
@@ -121,14 +121,14 @@ const readLog = (configDir) => {
121
121
  }
122
122
  };
123
123
  const USAGE = {
124
- status: 'status [--json] Runner, current card, parked cards and workflows',
124
+ status: 'status [--json] Runner, the card each workspace is on, parked cards and workflows',
125
125
  start: 'start Start the background runner',
126
- stop: 'stop Stop the runner and cancel the card it is working on',
126
+ stop: 'stop Stop the runner and cancel the cards it is working on',
127
127
  restart: 'restart Stop and start the runner',
128
128
  pause: 'pause Pick up no new cards; a card in progress carries on',
129
129
  resume: 'resume Pick up new cards again',
130
130
  run: 'run [CARD] [--workflow W] [--from STEP] Run now instead of waiting for the next minute',
131
- cancel: 'cancel [CARD] Stop the card being worked on and clear its labels',
131
+ cancel: 'cancel [CARD] Stop a card being worked on and clear its labels',
132
132
  retry: 'retry CARD [--workflow W] Queue a parked or cancelled card again',
133
133
  runs: 'runs [CARD] [--limit N] [--json] Past runs, newest first',
134
134
  show: 'show CARD [--json] Steps, outcome and labels of the latest run',
@@ -215,7 +215,7 @@ export async function cancelCurrent(ctx, c, deps) {
215
215
  }
216
216
  else
217
217
  ctx.log('Cleared a stale lock.');
218
- clearCurrent(ctx.configDir);
218
+ clearLock(ctx.configDir, c);
219
219
  }
220
220
  const realDeps = (ctx) => ({
221
221
  ours,
@@ -230,23 +230,30 @@ const realDeps = (ctx) => ({
230
230
  }
231
231
  },
232
232
  });
233
- async function startCmd(ctx) {
234
- if (!Object.keys(readAgentsConfig(ctx.configDir).workflows).length) {
235
- ctx.log('No workflows yet. Create one first: vsf agents workflow add.');
236
- return 1;
237
- }
238
- const trackers = trackersFor(ctx.configDir);
239
- for (const kind of trackersInUse(readAgentsConfig(ctx.configDir))) {
233
+ // One message per tracker whose key is missing or refused. The tick skips a
234
+ // tracker it cannot open, so these warn rather than stop the runner.
235
+ export async function trackerWarnings(kinds, trackers) {
236
+ const out = [];
237
+ for (const kind of kinds) {
240
238
  try {
241
239
  await trackers(kind).viewer();
242
240
  }
243
241
  catch (err) {
244
242
  const msg = err.message;
245
243
  const flag = kind === 'linear' ? '' : ` --${kind}`;
246
- ctx.log(/^No .* API key/.test(msg) ? msg : `${TRACKERS[kind].name} did not accept the key: ${msg}. Run vsf agents key${flag}.`);
247
- return 1;
244
+ out.push({ kind, message: /^No .* API key/.test(msg) ? msg : `${TRACKERS[kind].name} did not accept the key: ${msg}. Run vsf agents key${flag}.` });
248
245
  }
249
246
  }
247
+ return out;
248
+ }
249
+ async function startCmd(ctx) {
250
+ if (!Object.keys(readAgentsConfig(ctx.configDir).workflows).length) {
251
+ ctx.log('No workflows yet. Create one first: vsf agents workflow add.');
252
+ return 1;
253
+ }
254
+ for (const w of await trackerWarnings(trackersInUse(readAgentsConfig(ctx.configDir)), trackersFor(ctx.configDir))) {
255
+ ctx.log(`Warning: ${w.message.replace(/\.?$/, '.')} Workflows on ${TRACKERS[w.kind].name} are skipped until then.`);
256
+ }
250
257
  setPaused(ctx.configDir, false);
251
258
  if (!runnerService().start(ctx.home, program(ctx), logPath(ctx.configDir))) {
252
259
  ctx.log('Could not start the runner. Check vsf agents logs.');
@@ -256,13 +263,29 @@ async function startCmd(ctx) {
256
263
  }
257
264
  async function stopCmd(ctx) {
258
265
  runnerService().stop(ctx.home);
259
- // Stopping the runner may have stopped the pass; finish or tidy up whatever the lock still names.
266
+ // Stopping the runner may have stopped its runs; finish or tidy up whatever the locks still name.
267
+ for (const c of workspaceLocks(ctx.configDir))
268
+ await cancelCurrent(ctx, c, realDeps(ctx));
260
269
  const c = readCurrent(ctx.configDir);
261
270
  if (c)
262
271
  await cancelCurrent(ctx, c, realDeps(ctx));
263
272
  ctx.log('Runner stopped.');
264
273
  return 0;
265
274
  }
275
+ // The lock holds the round as the card's vs-vN label shows it; 0 before any round.
276
+ export function workingLine(current) {
277
+ if (!current)
278
+ return '';
279
+ if (!current.card)
280
+ return ' Checking now.';
281
+ return ` Working on ${current.card}${current.title ? ` "${current.title}"` : ''} (${[current.workflow, current.step, current.round ? `round ${current.round}` : '', `${current.elapsedMinutes}m`].filter(Boolean).join(', ')}).`;
282
+ }
283
+ const elapsed = (c) => (c.startedAt ? Math.max(0, Math.round((Date.now() - Date.parse(c.startedAt)) / 60_000)) : 0);
284
+ // One line per workspace: the card it is on, or idle.
285
+ export function workspaceLines(workspaces, running) {
286
+ const names = [...new Set([...workspaces, ...running.map((r) => workspaceOf(r))])];
287
+ return names.map((ws) => ` ${ws}:${workingLine(running.find((r) => workspaceOf(r) === ws) ?? null) || ' idle.'}`);
288
+ }
266
289
  const status = guard(async (args, ctx) => {
267
290
  const p = parse('status', args, { json: { type: 'boolean' } }, ctx);
268
291
  if (typeof p === 'number')
@@ -271,8 +294,11 @@ const status = guard(async (args, ctx) => {
271
294
  const runner = runnerState(ctx);
272
295
  const text = readLog(ctx.configDir);
273
296
  const lastTick = lastTickOf(text);
274
- const live = liveCurrent(ctx.configDir);
275
- const current = live ? { ...live, checking: !live.card, elapsedMinutes: live.startedAt ? Math.max(0, Math.round((Date.now() - Date.parse(live.startedAt)) / 60_000)) : 0 } : null;
297
+ const live = liveRuns(ctx.configDir);
298
+ const running = live.map((c) => ({ ...c, workspace: workspaceOf(c), elapsedMinutes: elapsed(c) }));
299
+ const dispatcher = liveCurrent(ctx.configDir);
300
+ // The first run, or the dispatcher checking for work, for dashboards that know one run only.
301
+ const current = running.length ? { ...running[0], checking: false } : dispatcher ? { ...dispatcher, checking: true, elapsedMinutes: elapsed(dispatcher) } : null;
276
302
  const seen = new Set();
277
303
  const parked = [];
278
304
  const interrupted = [];
@@ -288,20 +314,22 @@ const status = guard(async (args, ctx) => {
288
314
  const workflows = Object.keys(config.workflows).length;
289
315
  const projects = Object.keys(config.projects).length;
290
316
  if (p.values.json) {
291
- ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, parked, interrupted, workflows, projects }, null, 2));
317
+ ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, running, parked, interrupted, workflows, projects }, null, 2));
292
318
  return 0;
293
319
  }
294
- const working = current && !current.card
295
- ? ' Checking now.'
296
- : current
297
- ? ` Working on ${current.card ?? 'a card'}${current.title ? ` "${current.title}"` : ''} (${[current.workflow, current.step, current.round !== undefined ? `round ${current.round + 1}` : '', `${current.elapsedMinutes}m`].filter(Boolean).join(', ')}).`
298
- : '';
320
+ // One workspace keeps the single line; several get a line each.
321
+ const workspaces = workspacesInUse(config);
322
+ const perWorkspace = runner !== 'stopped' && (workspaces.length > 1 || running.some((r) => !workspaces.includes(r.workspace)));
323
+ const working = perWorkspace ? '' : workingLine(current);
299
324
  if (runner === 'running')
300
325
  ctx.log(`Runner: running.${working}`);
301
326
  else if (runner === 'paused')
302
327
  ctx.log(`Runner: paused, picks up no new cards. vsf agents resume to continue.${working}`);
303
328
  else
304
329
  ctx.log('Runner: stopped. vsf agents start to begin.');
330
+ if (perWorkspace)
331
+ for (const line of workspaceLines(workspaces, running))
332
+ ctx.log(line);
305
333
  if (lastTick)
306
334
  ctx.log(`Last check: ${clock(new Date(lastTick.at))}, ${lastTick.line.replace(/\.$/, '') || 'no details'}.`);
307
335
  for (const card of interrupted)
@@ -388,6 +416,20 @@ const run = guard(async (args, ctx) => {
388
416
  }
389
417
  return code;
390
418
  });
419
+ // The lock a cancel ends, or why there is none. Every run's lock counts, stale
420
+ // ones included, so a dead run can be tidied up; with none, the dispatcher's
421
+ // own lock, a check in progress. Without a card it must be the only one.
422
+ export function pickCancel(locks, dispatcher, card) {
423
+ const all = locks.length ? locks : dispatcher ? [dispatcher] : [];
424
+ if (!all.length)
425
+ return card ? `${card} is not running.` : 'Nothing is running.';
426
+ const name = (l) => `${l.card ?? 'a check'}${l.workspace ? ` in ${l.workspace}` : ''}`;
427
+ if (card)
428
+ return all.find((l) => l.card === card) ?? `${card} is not running. Running: ${all.map(name).join(', ')}.`;
429
+ if (all.length > 1)
430
+ return `More than one card is running: ${all.map(name).join(', ')}. Name one: vsf agents cancel CARD.`;
431
+ return all[0];
432
+ }
391
433
  const cancel = guard(async (args, ctx) => {
392
434
  const p = parse('cancel', args, {}, ctx);
393
435
  if (typeof p === 'number')
@@ -395,16 +437,12 @@ const cancel = guard(async (args, ctx) => {
395
437
  const card = p.positionals[0]?.toUpperCase();
396
438
  if (p.positionals.length > 1 || (card && !CARD_ID.test(card)))
397
439
  return usageError('cancel', ctx, 'Give one card id like ENG-12.');
398
- const c = readCurrent(ctx.configDir);
399
- if (!c) {
400
- ctx.log(card ? `${card} is not running.` : 'Nothing is running.');
401
- return card ? 1 : 0;
440
+ const found = pickCancel(workspaceLocks(ctx.configDir), readCurrent(ctx.configDir), card);
441
+ if (typeof found === 'string') {
442
+ ctx.log(found);
443
+ return found.startsWith('More than one') ? 2 : card ? 1 : 0;
402
444
  }
403
- if (card && c.card !== card) {
404
- ctx.log(`${c.card ?? 'Another card'} is running, not ${card}.`);
405
- return 1;
406
- }
407
- await cancelCurrent(ctx, c, realDeps(ctx));
445
+ await cancelCurrent(ctx, found, realDeps(ctx));
408
446
  return 0;
409
447
  });
410
448
  const retry = guard(async (args, ctx) => {
@@ -414,7 +452,7 @@ const retry = guard(async (args, ctx) => {
414
452
  const id = p.positionals[0]?.toUpperCase();
415
453
  if (p.positionals.length !== 1 || !CARD_ID.test(id))
416
454
  return usageError('retry', ctx, 'Give one card id like ENG-12.');
417
- if (liveCurrent(ctx.configDir)?.card === id) {
455
+ if (liveRuns(ctx.configDir).some((c) => c.card === id)) {
418
456
  ctx.log(`${id} is running now. vsf agents cancel ${id} first.`);
419
457
  return 1;
420
458
  }
@@ -484,7 +522,7 @@ const runs = guard(async (args, ctx) => {
484
522
  }
485
523
  if (!entries.length)
486
524
  ctx.log('No runs yet.');
487
- const live = liveCurrent(ctx.configDir);
525
+ const live = liveRuns(ctx.configDir);
488
526
  for (const e of entries)
489
527
  ctx.log(formatRunLine(e, Date.now(), live));
490
528
  return 0;
@@ -518,7 +556,7 @@ const show = guard(async (args, ctx) => {
518
556
  return 0;
519
557
  }
520
558
  ctx.log(`${latest.card} ${latest.title}`);
521
- ctx.log(`Workflow ${latest.workflow}, ${isInterrupted(all[0], liveCurrent(ctx.configDir)) ? 'interrupted' : latest.outcome}${latest.prUrl ? `, PR ${latest.prUrl}` : ''}`);
559
+ ctx.log(`Workflow ${latest.workflow}, ${isInterrupted(all[0], liveRuns(ctx.configDir)) ? 'interrupted' : latest.outcome}${latest.prUrl ? `, PR ${latest.prUrl}` : ''}`);
522
560
  if (labels)
523
561
  ctx.log(`Labels: ${labels.join(', ') || 'none'}`);
524
562
  for (const s of latest.steps) {
@@ -634,10 +672,10 @@ const clean = guard(async (args, ctx) => {
634
672
  if (ms === null)
635
673
  return usageError('clean', ctx, `${age} is not an age. Use a number of days or hours, like 14d or 36h.`);
636
674
  const cutoff = Date.now() - ms;
637
- const live = liveCurrent(ctx.configDir);
675
+ const live = new Set(liveRuns(ctx.configDir).map((c) => c.card));
638
676
  const wtRoot = join(agentsDir(ctx.configDir), 'worktrees');
639
677
  const worktrees = (existsSync(wtRoot) ? readdirSync(wtRoot) : [])
640
- .filter((card) => card !== live?.card)
678
+ .filter((card) => !live.has(card))
641
679
  .filter((card) => {
642
680
  const record = listRuns(ctx.configDir, card)[0]?.record;
643
681
  const ended = record?.endedAt ? Date.parse(record.endedAt) : mtime(join(wtRoot, card));
@@ -645,7 +683,7 @@ const clean = guard(async (args, ctx) => {
645
683
  })
646
684
  .map((card) => join(wtRoot, card));
647
685
  const old = listRuns(ctx.configDir).filter((e) => {
648
- if (e.record?.outcome === 'running' && e.card === live?.card)
686
+ if (e.record?.outcome === 'running' && live.has(e.card))
649
687
  return false;
650
688
  return (e.record?.endedAt ? Date.parse(e.record.endedAt) : mtime(e.dir)) < cutoff;
651
689
  });
@@ -675,12 +713,17 @@ const clean = guard(async (args, ctx) => {
675
713
  ctx.log(`Removed ${plural(worktrees.length, 'worktree')} and ${plural(old.length, 'run')}.`);
676
714
  return 0;
677
715
  });
678
- // Internal: the runner (launchd or systemd) runs this every minute.
679
- const tickCmd = async (_args, ctx) => {
716
+ // Internal: the runner (launchd or systemd) runs this every minute, and it
717
+ // starts itself with --card or --schedule for each run, one per workspace.
718
+ const tickCmd = async (args, ctx) => {
680
719
  const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
681
720
  try {
721
+ const { values } = parseArgs({ args, options: { card: { type: 'string' }, workflow: { type: 'string' }, schedule: { type: 'string' } }, strict: true });
682
722
  const config = readAgentsConfig(ctx.configDir);
683
- return await tick({ configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand });
723
+ const rc = { configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand };
724
+ if (values.card || values.workflow)
725
+ return await tick(rc, values.card?.toUpperCase(), { workflow: values.workflow, schedule: values.schedule, child: true });
726
+ return await dispatch(rc, { reload: () => readAgentsConfig(ctx.configDir) });
684
727
  }
685
728
  catch (err) {
686
729
  stamped(`Could not run the check: ${err.message}`);
@@ -723,8 +766,8 @@ export async function controlChecks(ctx) {
723
766
  ? { name: 'runner program', status: 'fail', detail: `runner points at a missing ${missing[0]}`, fix: 'vsf agents restart' }
724
767
  : { name: 'runner program', status: 'ok', detail: 'found' });
725
768
  }
726
- const c = readCurrent(ctx.configDir);
727
- if (c && !ours(c.pid))
769
+ const locks = [readCurrent(ctx.configDir), ...workspaceLocks(ctx.configDir)];
770
+ if (locks.some((c) => c && !ours(c.pid)))
728
771
  checks.push({ name: 'stale lock', status: 'warn', detail: 'a stale lock from a run that died', fix: 'vsf agents cancel' });
729
772
  if (existsSync(logPath(ctx.configDir))) {
730
773
  const { count, last } = scanRunnerErrors(readLog(ctx.configDir), Date.now());