@voidlabs.gg/voidshift-agents 0.5.1 → 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
@@ -10,8 +10,8 @@ export declare function stampDate(stamp: string): Date;
10
10
  export declare function countRounds(steps: {
11
11
  result?: string;
12
12
  }[]): number;
13
- export declare const isInterrupted: (e: RunEntry, live: Current | null) => boolean;
14
- 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;
15
15
  export declare function scanRunnerErrors(text: string, now: number): {
16
16
  count: number;
17
17
  last: string;
@@ -31,5 +31,9 @@ export declare function trackerWarnings(kinds: Kind[], trackers: Trackers): Prom
31
31
  export declare function workingLine(current: (Current & {
32
32
  elapsedMinutes: number;
33
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;
34
38
  export declare const controlCommands: Record<string, ToolCommand>;
35
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,
@@ -263,7 +263,9 @@ async function startCmd(ctx) {
263
263
  }
264
264
  async function stopCmd(ctx) {
265
265
  runnerService().stop(ctx.home);
266
- // 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));
267
269
  const c = readCurrent(ctx.configDir);
268
270
  if (c)
269
271
  await cancelCurrent(ctx, c, realDeps(ctx));
@@ -278,6 +280,12 @@ export function workingLine(current) {
278
280
  return ' Checking now.';
279
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(', ')}).`;
280
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
+ }
281
289
  const status = guard(async (args, ctx) => {
282
290
  const p = parse('status', args, { json: { type: 'boolean' } }, ctx);
283
291
  if (typeof p === 'number')
@@ -286,8 +294,11 @@ const status = guard(async (args, ctx) => {
286
294
  const runner = runnerState(ctx);
287
295
  const text = readLog(ctx.configDir);
288
296
  const lastTick = lastTickOf(text);
289
- const live = liveCurrent(ctx.configDir);
290
- 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;
291
302
  const seen = new Set();
292
303
  const parked = [];
293
304
  const interrupted = [];
@@ -303,16 +314,22 @@ const status = guard(async (args, ctx) => {
303
314
  const workflows = Object.keys(config.workflows).length;
304
315
  const projects = Object.keys(config.projects).length;
305
316
  if (p.values.json) {
306
- 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));
307
318
  return 0;
308
319
  }
309
- const working = workingLine(current);
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);
310
324
  if (runner === 'running')
311
325
  ctx.log(`Runner: running.${working}`);
312
326
  else if (runner === 'paused')
313
327
  ctx.log(`Runner: paused, picks up no new cards. vsf agents resume to continue.${working}`);
314
328
  else
315
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);
316
333
  if (lastTick)
317
334
  ctx.log(`Last check: ${clock(new Date(lastTick.at))}, ${lastTick.line.replace(/\.$/, '') || 'no details'}.`);
318
335
  for (const card of interrupted)
@@ -399,6 +416,20 @@ const run = guard(async (args, ctx) => {
399
416
  }
400
417
  return code;
401
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
+ }
402
433
  const cancel = guard(async (args, ctx) => {
403
434
  const p = parse('cancel', args, {}, ctx);
404
435
  if (typeof p === 'number')
@@ -406,16 +437,12 @@ const cancel = guard(async (args, ctx) => {
406
437
  const card = p.positionals[0]?.toUpperCase();
407
438
  if (p.positionals.length > 1 || (card && !CARD_ID.test(card)))
408
439
  return usageError('cancel', ctx, 'Give one card id like ENG-12.');
409
- const c = readCurrent(ctx.configDir);
410
- if (!c) {
411
- ctx.log(card ? `${card} is not running.` : 'Nothing is running.');
412
- return card ? 1 : 0;
413
- }
414
- if (card && c.card !== card) {
415
- ctx.log(`${c.card ?? 'Another card'} is running, not ${card}.`);
416
- return 1;
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;
417
444
  }
418
- await cancelCurrent(ctx, c, realDeps(ctx));
445
+ await cancelCurrent(ctx, found, realDeps(ctx));
419
446
  return 0;
420
447
  });
421
448
  const retry = guard(async (args, ctx) => {
@@ -425,7 +452,7 @@ const retry = guard(async (args, ctx) => {
425
452
  const id = p.positionals[0]?.toUpperCase();
426
453
  if (p.positionals.length !== 1 || !CARD_ID.test(id))
427
454
  return usageError('retry', ctx, 'Give one card id like ENG-12.');
428
- if (liveCurrent(ctx.configDir)?.card === id) {
455
+ if (liveRuns(ctx.configDir).some((c) => c.card === id)) {
429
456
  ctx.log(`${id} is running now. vsf agents cancel ${id} first.`);
430
457
  return 1;
431
458
  }
@@ -495,7 +522,7 @@ const runs = guard(async (args, ctx) => {
495
522
  }
496
523
  if (!entries.length)
497
524
  ctx.log('No runs yet.');
498
- const live = liveCurrent(ctx.configDir);
525
+ const live = liveRuns(ctx.configDir);
499
526
  for (const e of entries)
500
527
  ctx.log(formatRunLine(e, Date.now(), live));
501
528
  return 0;
@@ -529,7 +556,7 @@ const show = guard(async (args, ctx) => {
529
556
  return 0;
530
557
  }
531
558
  ctx.log(`${latest.card} ${latest.title}`);
532
- 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}` : ''}`);
533
560
  if (labels)
534
561
  ctx.log(`Labels: ${labels.join(', ') || 'none'}`);
535
562
  for (const s of latest.steps) {
@@ -645,10 +672,10 @@ const clean = guard(async (args, ctx) => {
645
672
  if (ms === null)
646
673
  return usageError('clean', ctx, `${age} is not an age. Use a number of days or hours, like 14d or 36h.`);
647
674
  const cutoff = Date.now() - ms;
648
- const live = liveCurrent(ctx.configDir);
675
+ const live = new Set(liveRuns(ctx.configDir).map((c) => c.card));
649
676
  const wtRoot = join(agentsDir(ctx.configDir), 'worktrees');
650
677
  const worktrees = (existsSync(wtRoot) ? readdirSync(wtRoot) : [])
651
- .filter((card) => card !== live?.card)
678
+ .filter((card) => !live.has(card))
652
679
  .filter((card) => {
653
680
  const record = listRuns(ctx.configDir, card)[0]?.record;
654
681
  const ended = record?.endedAt ? Date.parse(record.endedAt) : mtime(join(wtRoot, card));
@@ -656,7 +683,7 @@ const clean = guard(async (args, ctx) => {
656
683
  })
657
684
  .map((card) => join(wtRoot, card));
658
685
  const old = listRuns(ctx.configDir).filter((e) => {
659
- if (e.record?.outcome === 'running' && e.card === live?.card)
686
+ if (e.record?.outcome === 'running' && live.has(e.card))
660
687
  return false;
661
688
  return (e.record?.endedAt ? Date.parse(e.record.endedAt) : mtime(e.dir)) < cutoff;
662
689
  });
@@ -686,12 +713,17 @@ const clean = guard(async (args, ctx) => {
686
713
  ctx.log(`Removed ${plural(worktrees.length, 'worktree')} and ${plural(old.length, 'run')}.`);
687
714
  return 0;
688
715
  });
689
- // Internal: the runner (launchd or systemd) runs this every minute.
690
- 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) => {
691
719
  const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
692
720
  try {
721
+ const { values } = parseArgs({ args, options: { card: { type: 'string' }, workflow: { type: 'string' }, schedule: { type: 'string' } }, strict: true });
693
722
  const config = readAgentsConfig(ctx.configDir);
694
- 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) });
695
727
  }
696
728
  catch (err) {
697
729
  stamped(`Could not run the check: ${err.message}`);
@@ -734,8 +766,8 @@ export async function controlChecks(ctx) {
734
766
  ? { name: 'runner program', status: 'fail', detail: `runner points at a missing ${missing[0]}`, fix: 'vsf agents restart' }
735
767
  : { name: 'runner program', status: 'ok', detail: 'found' });
736
768
  }
737
- const c = readCurrent(ctx.configDir);
738
- if (c && !ours(c.pid))
769
+ const locks = [readCurrent(ctx.configDir), ...workspaceLocks(ctx.configDir)];
770
+ if (locks.some((c) => c && !ours(c.pid)))
739
771
  checks.push({ name: 'stale lock', status: 'warn', detail: 'a stale lock from a run that died', fix: 'vsf agents cancel' });
740
772
  if (existsSync(logPath(ctx.configDir))) {
741
773
  const { count, last } = scanRunnerErrors(readLog(ctx.configDir), Date.now());
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { basename, dirname, join, resolve, sep } from 'node:path';
5
5
  import { parseArgs } from 'node:util';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { AGENTS, AGENT_FILE, STEPS, TEMPLATES, isProducer, agentFolders, agentsDir, describeSteps, describeTrigger, describeTriggers, describeWorkflow, stepEnabled, stepId, visibleAgents, readAgentsConfig, writeAgentsConfig, DEFAULTS } from "./config.js";
7
+ import { AGENTS, AGENT_FILE, STEPS, TEMPLATES, isProducer, agentFolders, agentsDir, describeSteps, describeTrigger, describeTriggers, describeWorkflow, stepEnabled, stepId, visibleAgents, readAgentsConfig, writeAgentsConfig, workspaceOf, DEFAULTS, DEFAULT_WORKSPACE, WORKSPACE_NAME } from "./config.js";
8
8
  import { controlChecks, controlCommands, controlUsage } from "./control.js";
9
9
  import { keyStore, looksLikeLinearKey, readLinearKey, readSecret, storeLinearKey, storeSecret } from "./secrets.js";
10
10
  import { TRACKERS } from "./tracker.js";
@@ -14,7 +14,7 @@ import { trackersInUse } from "./control.js";
14
14
  import { runnerService } from "./service.js";
15
15
  import { initProfile, profileChecks, profileDir, readRules, renderClaudeSettings, CLAUDE_SETTINGS, RULES_FILE } from "./profile.js";
16
16
  import { createLinear } from "./linear.js";
17
- import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateAgentFolder, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
17
+ import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, projectProblems, renameKey, sameTrigger, validateAgentFolder, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
18
18
  import { parseStep, resolveName, stepNames } from "./names.js";
19
19
  import { findRepos } from "./repos.js";
20
20
  import { defaultBranch, sh } from "./git.js";
@@ -239,7 +239,8 @@ async function promptProject(ui, config, current = {}, edit = false, allowExisti
239
239
  const describeProject = (config, name) => {
240
240
  const p = config.projects[name];
241
241
  const used = workflowsUsing(config, name);
242
- return `${name} ${tilde(p.path)}, PRs into ${p.base ?? 'the default branch'}, ${used.length ? `used by ${used.join(', ')}` : 'not used by any workflow'}`;
242
+ const where = [p.workspace ? `workspace ${p.workspace}` : '', p.match?.length ? `matches ${p.match.join(', ')}` : ''].filter(Boolean);
243
+ return `${name} ${tilde(p.path)}, PRs into ${p.base ?? 'the default branch'}, ${where.map((w) => `${w}, `).join('')}${used.length ? `used by ${used.join(', ')}` : 'not used by any workflow'}`;
243
244
  };
244
245
  // Named local repos the workflows run in. Triggers point at them by name.
245
246
  async function setupProjects(ui, config, log) {
@@ -366,11 +367,14 @@ const USAGE = [
366
367
  ['workflow rm NAME [--yes]', 'Delete a workflow'],
367
368
  ['workflow move NAME (--first | --last | --before OTHER | --after OTHER)', 'Change the match order'],
368
369
  ['projects [--json]', 'List projects'],
369
- ['project add [NAME] [--path P] [--base B]', 'Add a project; prompts for what is missing'],
370
+ ['project add [NAME] [--path P] [--base B] [--workspace W] [--match NAME]...', 'Add a project; prompts for what is missing. --match routes cards of that tracker project here, whatever the workflow says'],
370
371
  ['project show NAME [--json]', 'Show one project'],
371
- ['project edit NAME [--path P] [--base B]', 'Change a project; no flags asks'],
372
+ ['project edit NAME [--path P] [--base B] [--workspace W] [--match NAME]...', 'Change a project; no flags asks. An empty --workspace or --match clears it'],
372
373
  ['project rename OLD NEW', 'Rename a project and the workflows that use it'],
373
374
  ['project rm NAME [--yes]', 'Delete a project nothing uses'],
375
+ ['workspace add NAME --root DIR [--context FILE]', 'Add a workspace: a folder of repos with its own run queue. Steps carry FILE, or DIR/CONTEXT.md'],
376
+ ['workspace list [--json]', 'List workspaces and their projects'],
377
+ ['workspace rm NAME', 'Delete a workspace no project uses'],
374
378
  ['homes [--json]', 'Show where the agents are linked'],
375
379
  ['link DIR [--claude | --codex]', 'Link the agents into a config folder'],
376
380
  ['unlink DIR', 'Remove the agents from a config folder'],
@@ -789,9 +793,31 @@ function pickProject(config, input, ctx) {
789
793
  }
790
794
  return r.name;
791
795
  }
796
+ const PROJECT_FLAGS = { path: { type: 'string' }, base: { type: 'string' }, workspace: { type: 'string' }, match: { type: 'string', multiple: true } };
797
+ // Applies --workspace and --match to a project. An empty value clears either.
798
+ // Returns a complaint when the workspace or a match does not fit.
799
+ function applyWorkspaceFlags(config, name, project, v) {
800
+ if (v.workspace !== undefined) {
801
+ const ws = String(v.workspace).trim();
802
+ if (ws && ws !== DEFAULT_WORKSPACE && !config.workspaces[ws])
803
+ return `No workspace named ${ws}. vsf agents workspace add creates it.`;
804
+ if (ws && ws !== DEFAULT_WORKSPACE)
805
+ project.workspace = ws;
806
+ else
807
+ delete project.workspace;
808
+ }
809
+ if (v.match !== undefined) {
810
+ const match = v.match.map((m) => m.trim()).filter(Boolean);
811
+ if (match.length)
812
+ project.match = match;
813
+ else
814
+ delete project.match;
815
+ }
816
+ return projectProblems({ ...config.projects, [name]: project }, config.workspaces)[0] ?? null;
817
+ }
792
818
  const projectSubs = {
793
819
  async add(args, ctx) {
794
- const p = begin('project add', args, { path: { type: 'string' }, base: { type: 'string' } }, ctx);
820
+ const p = begin('project add', args, PROJECT_FLAGS, ctx);
795
821
  if (typeof p === 'number')
796
822
  return p;
797
823
  if (p.positionals.length > 1) {
@@ -838,6 +864,11 @@ const projectSubs = {
838
864
  name = r.name;
839
865
  project = r.project;
840
866
  }
867
+ const bad = applyWorkspaceFlags(config, name, project, p.values);
868
+ if (bad) {
869
+ ctx.log(bad);
870
+ return 1;
871
+ }
841
872
  config.projects[name] = project;
842
873
  save(ctx, config);
843
874
  ctx.log(`Project ${name}: ${tilde(project.path)}${project.base ? `, PRs into ${project.base}` : ''}`);
@@ -861,12 +892,15 @@ const projectSubs = {
861
892
  ctx.log(`Project ${name}`);
862
893
  ctx.log(`Path: ${tilde(proj.path)}`);
863
894
  ctx.log(`PRs into: ${proj.base ?? 'the default branch'}`);
895
+ ctx.log(`Workspace: ${workspaceOf(proj)}`);
896
+ if (proj.match?.length)
897
+ ctx.log(`Matches: ${proj.match.join(', ')}`);
864
898
  ctx.log(`Used by: ${used.length ? used.join(', ') : 'no workflows'}`);
865
899
  }
866
900
  return 0;
867
901
  },
868
902
  async edit(args, ctx) {
869
- const p = begin('project edit', args, { path: { type: 'string' }, base: { type: 'string' } }, ctx);
903
+ const p = begin('project edit', args, PROJECT_FLAGS, ctx);
870
904
  if (typeof p === 'number')
871
905
  return p;
872
906
  if (!needArgs(p, 1, 'project edit', ctx))
@@ -877,9 +911,9 @@ const projectSubs = {
877
911
  return 1;
878
912
  const current = config.projects[name];
879
913
  const v = p.values;
880
- if (v.path === undefined && v.base === undefined) {
914
+ if (v.path === undefined && v.base === undefined && v.workspace === undefined && v.match === undefined) {
881
915
  if (!interactive()) {
882
- ctx.log('Nothing to change. Give --path or --base, or run this in a terminal to be asked.');
916
+ ctx.log('Nothing to change. Give --path, --base, --workspace or --match, or run this in a terminal to be asked.');
883
917
  ctx.log(usageOf('project edit'));
884
918
  return 2;
885
919
  }
@@ -887,7 +921,8 @@ const projectSubs = {
887
921
  }
888
922
  else {
889
923
  const path = v.path !== undefined ? resolve(expandHome(v.path)) : current.path;
890
- if (!isRepo(path)) {
924
+ // Only a new path needs checking; a workspace or match change keeps the repo.
925
+ if (v.path !== undefined && !isRepo(path)) {
891
926
  ctx.log(`${tilde(path)} is not a git repository.`);
892
927
  return 1;
893
928
  }
@@ -899,6 +934,11 @@ const projectSubs = {
899
934
  else
900
935
  delete next.base;
901
936
  }
937
+ const bad = applyWorkspaceFlags(config, name, next, v);
938
+ if (bad) {
939
+ ctx.log(bad);
940
+ return 1;
941
+ }
902
942
  config.projects[name] = next;
903
943
  }
904
944
  save(ctx, config);
@@ -960,6 +1000,87 @@ const projectSubs = {
960
1000
  return 0;
961
1001
  },
962
1002
  };
1003
+ // ---- workspaces ----
1004
+ const projectsIn = (config, ws) => Object.entries(config.projects).filter(([, p]) => workspaceOf(p) === ws).map(([n]) => n);
1005
+ const workspaceSubs = {
1006
+ async add(args, ctx) {
1007
+ const p = begin('workspace add', args, { root: { type: 'string' }, context: { type: 'string' } }, ctx);
1008
+ if (typeof p === 'number')
1009
+ return p;
1010
+ if (!needArgs(p, 1, 'workspace add', ctx))
1011
+ return 2;
1012
+ const name = p.positionals[0].trim();
1013
+ if (!WORKSPACE_NAME.test(name)) {
1014
+ ctx.log('A workspace name is letters, digits, dashes and underscores.');
1015
+ return 2;
1016
+ }
1017
+ if (!p.values.root) {
1018
+ ctx.log('Missing --root.');
1019
+ ctx.log(usageOf('workspace add'));
1020
+ return 2;
1021
+ }
1022
+ const config = readAgentsConfig(ctx.configDir);
1023
+ if (config.workspaces[name]) {
1024
+ ctx.log(`A workspace named ${name} already exists.`);
1025
+ return 1;
1026
+ }
1027
+ const root = resolve(expandHome(p.values.root));
1028
+ if (!existsSync(root)) {
1029
+ ctx.log(`${tilde(root)} does not exist.`);
1030
+ return 1;
1031
+ }
1032
+ const context = p.values.context ? resolve(root, expandHome(p.values.context)) : undefined;
1033
+ if (context && !existsSync(context)) {
1034
+ ctx.log(`${tilde(context)} does not exist.`);
1035
+ return 1;
1036
+ }
1037
+ config.workspaces[name] = { root, ...(context ? { context } : {}) };
1038
+ save(ctx, config);
1039
+ ctx.log(`Workspace ${name}: ${tilde(root)}. vsf agents project edit P --workspace ${name} puts a project in it.`);
1040
+ return 0;
1041
+ },
1042
+ async list(args, ctx) {
1043
+ const p = begin('workspace list', args, { json: { type: 'boolean' } }, ctx);
1044
+ if (typeof p === 'number')
1045
+ return p;
1046
+ const config = readAgentsConfig(ctx.configDir);
1047
+ const names = [...new Set([...Object.keys(config.workspaces), ...Object.values(config.projects).map(workspaceOf)])];
1048
+ if (p.values.json) {
1049
+ out(ctx, names.map((name) => ({ name, ...config.workspaces[name], projects: projectsIn(config, name) })));
1050
+ return 0;
1051
+ }
1052
+ if (!names.length)
1053
+ ctx.log('No workspaces yet. Every project runs in the default one; vsf agents workspace add creates another.');
1054
+ for (const name of names) {
1055
+ const w = config.workspaces[name];
1056
+ const projects = projectsIn(config, name);
1057
+ ctx.log(`${name} ${w ? tilde(w.root) : 'no root'}${w?.context ? `, context ${tilde(w.context)}` : ''}, ${projects.length ? projects.join(', ') : 'no projects'}`);
1058
+ }
1059
+ return 0;
1060
+ },
1061
+ async rm(args, ctx) {
1062
+ const p = begin('workspace rm', args, {}, ctx);
1063
+ if (typeof p === 'number')
1064
+ return p;
1065
+ if (!needArgs(p, 1, 'workspace rm', ctx))
1066
+ return 2;
1067
+ const config = readAgentsConfig(ctx.configDir);
1068
+ const name = p.positionals[0].trim();
1069
+ if (!config.workspaces[name]) {
1070
+ ctx.log(`No workspace named ${name}.`);
1071
+ return 1;
1072
+ }
1073
+ const used = projectsIn(config, name);
1074
+ if (used.length) {
1075
+ ctx.log(`Used by ${used.join(', ')}. Move those projects first: vsf agents project edit P --workspace OTHER.`);
1076
+ return 1;
1077
+ }
1078
+ delete config.workspaces[name];
1079
+ save(ctx, config);
1080
+ ctx.log(`Deleted workspace ${name}.`);
1081
+ return 0;
1082
+ },
1083
+ };
963
1084
  // ---- homes ----
964
1085
  // Per enabled agent: ours, taken by something else, or missing.
965
1086
  function homeReport(config, h) {
@@ -1242,6 +1363,7 @@ const agents = {
1242
1363
  return 0;
1243
1364
  },
1244
1365
  project: (args, ctx) => group('project', projectSubs, args, ctx),
1366
+ workspace: (args, ctx) => group('workspace', workspaceSubs, args, ctx),
1245
1367
  homes: homeCommands.homes,
1246
1368
  link: homeCommands.link,
1247
1369
  unlink: homeCommands.unlink,
package/dist/manage.d.ts CHANGED
@@ -18,4 +18,5 @@ export type Place = {
18
18
  };
19
19
  export declare function moveKey<T>(record: Record<string, T>, name: string, place: Place): Record<string, T>;
20
20
  export declare const workflowsUsing: (config: Pick<AgentsConfig, "workflows">, project: string) => string[];
21
- export declare function validateConfig(value: unknown, saved?: Pick<AgentsConfig, 'projects' | 'workflows'>): string[];
21
+ export declare function projectProblems(projects: AgentsConfig['projects'], workspaces: AgentsConfig['workspaces']): string[];
22
+ export declare function validateConfig(value: unknown, saved?: Pick<AgentsConfig, 'projects' | 'workflows'> & Partial<Pick<AgentsConfig, 'workspaces'>>): string[];
package/dist/manage.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { basename, join, resolve } from 'node:path';
3
- import { AGENTS, AGENT_FILE, OUTPUTS, STEP_ID, isBuiltin, readAgentFolder, stepFile, describeTrigger, triggerSpec } from "./config.js";
3
+ import { AGENTS, AGENT_FILE, DEFAULT_WORKSPACE, OUTPUTS, STEP_ID, WORKSPACE_NAME, isBuiltin, readAgentFolder, stepFile, describeTrigger, triggerSpec } from "./config.js";
4
4
  import { parseCron } from "./cron.js";
5
5
  const isInt = (v, min, max) => typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
6
6
  // The line a step's prompt must ask for, so the runner can read the result.
@@ -185,6 +185,34 @@ export function moveKey(record, name, place) {
185
185
  }
186
186
  export const workflowsUsing = (config, project) => Object.entries(config.workflows).filter(([, w]) => w.project === project).map(([n]) => n);
187
187
  const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
188
+ // What is wrong with the projects' workspaces and matches: a workspace that
189
+ // does not exist, or a tracker project name two projects both claim.
190
+ export function projectProblems(projects, workspaces) {
191
+ const problems = [];
192
+ const claimed = new Map();
193
+ for (const [name, p] of Object.entries(projects)) {
194
+ if (!isObj(p))
195
+ continue;
196
+ if (p.workspace !== undefined && (typeof p.workspace !== 'string' || (p.workspace !== DEFAULT_WORKSPACE && !Object.hasOwn(workspaces, p.workspace)))) {
197
+ problems.push(`Project ${name}: workspace ${String(p.workspace)} does not exist. vsf agents workspace add creates it.`);
198
+ }
199
+ if (p.match === undefined)
200
+ continue;
201
+ if (!Array.isArray(p.match) || p.match.some((m) => typeof m !== 'string' || !m.trim())) {
202
+ problems.push(`Project ${name}: match must list tracker project names.`);
203
+ continue;
204
+ }
205
+ for (const m of p.match) {
206
+ const key = m.trim().toLowerCase();
207
+ const other = claimed.get(key);
208
+ if (other && other !== name)
209
+ problems.push(`Projects ${other} and ${name} both match ${m}.`);
210
+ else
211
+ claimed.set(key, name);
212
+ }
213
+ }
214
+ return problems;
215
+ }
188
216
  // Everything wrong with a whole agents config, e.g. one edited by hand.
189
217
  // `saved` is the config on disk: only what the write changes is checked, so a
190
218
  // moved folder or a retired agent elsewhere does not block an unrelated edit.
@@ -209,6 +237,23 @@ export function validateConfig(value, saved) {
209
237
  else if (saved?.projects?.[name]?.path !== p.path && !existsSync(join(p.path, '.git')))
210
238
  problems.push(`Project ${name}: ${p.path} is not a git repository.`);
211
239
  }
240
+ // Older configs have no workspaces: every project is in the default one.
241
+ const workspaces = value.workspaces === undefined ? {} : isObj(value.workspaces) ? value.workspaces : null;
242
+ if (!workspaces)
243
+ problems.push('workspaces must be an object.');
244
+ for (const [name, w] of Object.entries(workspaces ?? {})) {
245
+ if (!WORKSPACE_NAME.test(name))
246
+ problems.push(`Workspace ${name} must be letters, digits, dashes and underscores.`);
247
+ if (!isObj(w) || typeof w.root !== 'string' || !w.root)
248
+ problems.push(`Workspace ${name} needs a root folder.`);
249
+ else {
250
+ if (saved?.workspaces?.[name]?.root !== w.root && !existsSync(w.root))
251
+ problems.push(`Workspace ${name}: ${w.root} does not exist.`);
252
+ if (w.context !== undefined && typeof w.context !== 'string')
253
+ problems.push(`Workspace ${name} context must be a file name.`);
254
+ }
255
+ }
256
+ problems.push(...projectProblems((projects ?? {}), (workspaces ?? {})));
212
257
  if (!Array.isArray(value.enabled) || value.enabled.some((a) => typeof a !== 'string' || !(a in AGENTS)))
213
258
  problems.push(`enabled must list agents from: ${Object.keys(AGENTS).join(', ')}.`);
214
259
  if (value.commits !== undefined && (!isObj(value.commits) || Object.entries(value.commits).some(([k, v]) => !['signOff', 'dropAiTrailers'].includes(k) || typeof v !== 'boolean')))
package/dist/runner.d.ts CHANGED
@@ -16,15 +16,38 @@ type Job = {
16
16
  card: Card;
17
17
  trigger: Trigger;
18
18
  workflow: string;
19
+ workspace: string;
19
20
  };
20
21
  export declare function cronCard(workflow: string, task: string, at: Date): Card;
21
- export declare function nextJob(rc: RunContext, only?: string, workflowName?: string, opts?: {
22
+ export interface JobQuery {
23
+ only?: string;
24
+ workflow?: string;
25
+ schedule?: string;
22
26
  force?: boolean;
27
+ busy?: Set<string>;
28
+ skip?: Set<string>;
29
+ limit?: number;
23
30
  now?: Date;
24
- }): Promise<Job | null>;
31
+ }
32
+ export declare function findJobs(rc: RunContext, q?: JobQuery): Promise<Job[]>;
33
+ export declare function nextJob(rc: RunContext, only?: string, workflowName?: string, opts?: Omit<JobQuery, 'only' | 'workflow' | 'limit'>): Promise<Job | null>;
25
34
  export declare function cancelCard(tracker: Tracker, configDir: string, cardId: string, label: string, source?: 'linear' | 'voidtrack'): Promise<boolean>;
26
35
  export declare function tick(rc: RunContext, only?: string, opts?: {
27
36
  workflow?: string;
28
37
  from?: string;
38
+ schedule?: string;
39
+ child?: boolean;
29
40
  }): Promise<number>;
41
+ export interface ChildRun {
42
+ kill(signal: NodeJS.Signals): unknown;
43
+ once(event: 'exit' | 'error', listener: (err?: Error) => void): unknown;
44
+ exitCode: number | null;
45
+ }
46
+ export interface DispatchDeps {
47
+ start: (args: string[]) => ChildRun;
48
+ waitMs: number;
49
+ reload?: () => AgentsConfig;
50
+ }
51
+ export declare const jobArgs: (job: Job) => string[];
52
+ export declare function dispatch(rc: RunContext, over?: Partial<DispatchDeps>): Promise<number>;
30
53
  export {};
package/dist/runner.js CHANGED
@@ -1,14 +1,15 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import { existsSync, mkdirSync } from 'node:fs';
2
3
  import { basename, join } from 'node:path';
3
4
  import { LABELS, PLAN_HEADING, buildPrompt, findingsFor, fixTarget, isStatusLabel, latestPlan, needsPlan, parseDevResult, parseVerdict, planBody, qaPassed, stageLabels, verdictBlock, } from "./workflow.js";
4
- import { agentsDir, hasWork, isCardTrigger, resolveStep, stepId, stepIdLabel } from "./config.js";
5
+ import { agentsDir, hasWork, isCardTrigger, resolveProject, resolveStep, stepId, stepIdLabel, workspaceContext, workspaceOf } from "./config.js";
5
6
  import { lastDue, parseCron } from "./cron.js";
6
7
  import { runAgent, stopAgents } from "./engine.js";
7
8
  import { commitsAhead, enforceCommitPolicy, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
8
9
  import { noTracker } from "./tracker.js";
9
10
  import { recall, syncMemory, vaultPath } from "./memory.js";
10
11
  import { claudeSettingsFile, readRules, rulesSection } from "./profile.js";
11
- import { bootTime, clearCurrent, isPaused, ours, readCurrent, readPending, writeCurrent, writePending, writeRun, readCronState, writeCronState } from "./state.js";
12
+ import { acquireWorkspace, clearCurrent, heldByOther, isPaused, liveRuns, readCurrent, readPending, releaseWorkspace, writeCurrent, writePending, writeRun, writeWorkspace, readCronState, writeCronState } from "./state.js";
12
13
  const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
13
14
  class Park extends Error {
14
15
  }
@@ -55,13 +56,14 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
55
56
  const writeBack = isCardTrigger(trigger) ? (workflow.writeBack ?? {}) : {};
56
57
  const labelsOn = writeBack.labels !== false;
57
58
  const startedAt = new Date().toISOString();
58
- 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', startedAt };
59
+ const workspace = workspaceOf(project);
60
+ 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 };
59
61
  active = { record, runDir };
60
62
  const save = () => writeRun(runDir, record);
61
63
  save();
62
64
  let loops = 0;
63
- // The lock tells status and cancel what is running.
64
- const mark = (step) => writeCurrent(rc.configDir, { pid: process.pid, card: card.identifier, title: card.title, workflow: workflowName, step, round: loops, startedAt, runDir, source: trigger.source });
65
+ // The workspace lock tells status and cancel what is running.
66
+ const mark = (step) => writeWorkspace(rc.configDir, workspace, { pid: process.pid, card: card.identifier, title: card.title, workflow: workflowName, step, round: loops, startedAt, runDir, source: trigger.source });
65
67
  // Written before the first tracker call, so a cancel always knows the card.
66
68
  mark('Starting');
67
69
  const begin = (def) => {
@@ -157,7 +159,8 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
157
159
  const pulled = syncMemory(rc.selfCommand, rc.configDir);
158
160
  if (pulled)
159
161
  say(pulled);
160
- const known = [recall(rc.selfCommand, rc.configDir, `${workflow.project} ${card.title}\n${card.description ?? ''}`), vault ? memoryNote(vault) : '', rules].filter(Boolean).join('\n\n');
162
+ const context = workspaceContext(config, workspace);
163
+ 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');
161
164
  const worktree = join(agentsDir(rc.configDir), 'worktrees', card.identifier);
162
165
  ensureWorktree(project.path, worktree, card.branchName, base);
163
166
  say(`working in ${worktree} on ${card.branchName}`);
@@ -343,7 +346,9 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
343
346
  say(shared);
344
347
  }
345
348
  }
346
- // A scheduled run's stand-in for a card: the task, on a branch of its own.
349
+ // The workspace whose queue a job waits in. A workflow whose project is
350
+ // missing still queues somewhere, so its run can say what is wrong.
351
+ const jobWorkspace = (config, workflow, card) => workspaceOf(resolveProject(config, workflow, card)?.project);
347
352
  export function cronCard(workflow, task, at) {
348
353
  const stamp = at.toISOString().slice(0, 16).replace(/[-:T]/g, '');
349
354
  const slug = workflow.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'cron';
@@ -360,21 +365,24 @@ export function cronCard(workflow, task, at) {
360
365
  };
361
366
  }
362
367
  const cronKey = (workflow, schedule) => `${workflow} ${schedule}`;
363
- // The first job waiting on any workflow's triggers, in workflow order: a card
364
- // carrying a trigger label, or a schedule that came due since it last fired.
365
- // A schedule seen for the first time starts counting from now. With a card,
366
- // only that card is considered; with a workflow name, only that workflow, and
367
- // `force` fires its schedule now.
368
- export async function nextJob(rc, only, workflowName, opts = {}) {
369
- const now = opts.now ?? new Date();
370
- const workflows = Object.entries(rc.config.workflows).filter(([name]) => !workflowName || name === workflowName);
368
+ // Jobs waiting on the workflows' triggers, in workflow order, at most one per
369
+ // workspace and none in a busy one: a card carrying a trigger label, or a
370
+ // schedule that came due since it last fired. A schedule seen for the first
371
+ // time starts counting from now, and one whose workspace is busy keeps waiting
372
+ // without being marked as fired.
373
+ export async function findJobs(rc, q = {}) {
374
+ const now = q.now ?? new Date();
375
+ const limit = q.limit ?? Infinity;
376
+ const taken = new Set(q.busy ?? []);
377
+ const out = [];
378
+ const workflows = Object.entries(rc.config.workflows).filter(([name]) => !q.workflow || name === q.workflow);
371
379
  let pinned = null;
372
- if (only) {
373
- const team = only.split('-')[0].toUpperCase();
380
+ if (q.only) {
381
+ const team = q.only.split('-')[0].toUpperCase();
374
382
  const sources = [...new Set(workflows.flatMap(([, w]) => w.triggers.filter(isCardTrigger).filter((t) => t.team === team).map((t) => t.source)))];
375
383
  for (const source of sources) {
376
384
  try {
377
- pinned = { card: await rc.trackers(source).card(only), source };
385
+ pinned = { card: await rc.trackers(source).card(q.only), source };
378
386
  break;
379
387
  }
380
388
  catch (err) {
@@ -383,32 +391,50 @@ export async function nextJob(rc, only, workflowName, opts = {}) {
383
391
  }
384
392
  }
385
393
  if (!pinned)
386
- return null;
394
+ return [];
387
395
  }
388
396
  const cron = readCronState(rc.configDir);
389
397
  let cronChanged = false;
398
+ const take = (job, workspace) => {
399
+ taken.add(workspace);
400
+ out.push({ ...job, workspace });
401
+ return out.length >= limit;
402
+ };
390
403
  try {
391
404
  for (const [name, workflow] of workflows) {
392
405
  for (const trigger of workflow.triggers) {
393
406
  if (!isCardTrigger(trigger)) {
394
- const schedule = only ? null : parseCron(trigger.schedule);
407
+ const schedule = q.only || (q.schedule && q.schedule !== trigger.schedule) ? null : parseCron(trigger.schedule);
395
408
  if (!schedule)
396
409
  continue;
397
410
  const key = cronKey(name, trigger.schedule);
398
411
  const last = cron[key];
399
- const due = opts.force ? now : last ? lastDue(schedule, new Date(last), now) : null;
400
- if (!last || due) {
412
+ if (!last) {
401
413
  cron[key] = now.toISOString();
402
414
  cronChanged = true;
403
415
  }
404
- if (due)
405
- return { card: cronCard(name, trigger.task, due), trigger, workflow: name };
416
+ const due = q.force ? now : last ? lastDue(schedule, new Date(last), now) : null;
417
+ if (!due)
418
+ continue;
419
+ const card = cronCard(name, trigger.task, due);
420
+ const workspace = jobWorkspace(rc.config, workflow, card);
421
+ if (taken.has(workspace))
422
+ continue;
423
+ cron[key] = now.toISOString();
424
+ cronChanged = true;
425
+ if (take({ card, trigger, workflow: name }, workspace))
426
+ return out;
406
427
  continue;
407
428
  }
429
+ // A run for one schedule takes no labelled cards.
430
+ if (q.schedule)
431
+ continue;
408
432
  // A pinned card may have lost its label (a parked card re-run by hand), so match by team.
409
433
  if (pinned) {
410
- if (pinned.source === trigger.source && pinned.card.teamKey === trigger.team)
411
- return { card: pinned.card, trigger, workflow: name };
434
+ if (pinned.source === trigger.source && pinned.card.teamKey === trigger.team) {
435
+ take({ card: pinned.card, trigger, workflow: name }, jobWorkspace(rc.config, workflow, pinned.card));
436
+ return out;
437
+ }
412
438
  continue;
413
439
  }
414
440
  let cards;
@@ -420,18 +446,33 @@ export async function nextJob(rc, only, workflowName, opts = {}) {
420
446
  rc.log(`Could not check ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} for ${trigger.team} + ${trigger.label} (${err.message}). Trying again next minute.`);
421
447
  continue;
422
448
  }
423
- const card = cards.find((c) => c.teamKey === trigger.team && c.labels.includes(trigger.label));
424
- if (card)
425
- return { card, trigger, workflow: name };
449
+ for (const card of cards) {
450
+ if (card.teamKey !== trigger.team || !card.labels.includes(trigger.label))
451
+ continue;
452
+ // A card already taken this pass, by an earlier trigger, is not run twice.
453
+ if (q.skip?.has(card.identifier) || out.some((j) => j.card.identifier === card.identifier))
454
+ continue;
455
+ const workspace = jobWorkspace(rc.config, workflow, card);
456
+ if (taken.has(workspace))
457
+ continue;
458
+ if (take({ card, trigger, workflow: name }, workspace))
459
+ return out;
460
+ }
426
461
  }
427
462
  }
428
- return null;
463
+ return out;
429
464
  }
430
465
  finally {
431
466
  if (cronChanged)
432
467
  writeCronState(rc.configDir, cron);
433
468
  }
434
469
  }
470
+ // The first job waiting, as findJobs finds it. With a card, only that card is
471
+ // considered; with a workflow name, only that workflow, and `force` fires its
472
+ // schedule now.
473
+ export async function nextJob(rc, only, workflowName, opts = {}) {
474
+ return (await findJobs(rc, { ...opts, only, workflow: workflowName, limit: 1 }))[0] ?? null;
475
+ }
435
476
  // Retries tracker writes that failed while parking. Never throws.
436
477
  async function flushPending(rc) {
437
478
  const left = [];
@@ -482,21 +523,20 @@ function queueCancel(configDir, cardId, comment, source = 'linear') {
482
523
  // nothing more to do
483
524
  }
484
525
  }
485
- // One pass for the background runner. A lock keeps two passes from working at once.
526
+ // Runs one job in its workspace's queue: a pinned card, a workflow's schedule,
527
+ // or the first job waiting in a free workspace. The workspace lock keeps two
528
+ // runs from working in one workspace at once. `child` is a run the dispatcher
529
+ // started, which leaves the pending writes to it.
486
530
  export async function tick(rc, only, opts = {}) {
487
- const held = readCurrent(rc.configDir);
488
- const booted = bootTime();
489
- // A lock from before the last boot cannot be a live run, whatever its pid is now.
490
- const fromBeforeBoot = !!(held?.startedAt && booted && new Date(held.startedAt) < booted);
491
- if (held && held.pid !== process.pid && !fromBeforeBoot && ours(held.pid)) {
492
- if (only)
493
- rc.log(`The runner is busy with ${held.card ?? 'a check'}. Try again when it finishes.`);
494
- return only ? 1 : 0;
495
- }
496
- writeCurrent(rc.configDir, { pid: process.pid, startedAt: new Date().toISOString() });
497
- // A stop (Ctrl+C, launchd, shutdown) ends the agents, marks the run, and
498
- // takes the working label off the card so it is never left stuck.
531
+ let held = null;
532
+ // A stop (Ctrl+C, the dispatcher, launchd, shutdown) ends the agents, marks
533
+ // the run, and takes the working label off the card so it is never left stuck.
534
+ // The dispatcher and the service can both signal, so a second one is ignored.
535
+ let stopping = false;
499
536
  const onSignal = async () => {
537
+ if (stopping)
538
+ return;
539
+ stopping = true;
500
540
  await stopAgents();
501
541
  const running = active && active.record.outcome === 'running' ? active : null;
502
542
  if (active) {
@@ -519,18 +559,21 @@ export async function tick(rc, only, opts = {}) {
519
559
  if (!finished)
520
560
  queueCancel(rc.configDir, card, `**Cancelled.** Add \`${trigger}\` again to restart.`, source);
521
561
  }
522
- clearCurrent(rc.configDir);
562
+ if (held)
563
+ releaseWorkspace(rc.configDir, held);
523
564
  process.exit(143);
524
565
  };
525
- process.once('SIGTERM', onSignal);
526
- process.once('SIGINT', onSignal);
566
+ process.on('SIGTERM', onSignal);
567
+ process.on('SIGINT', onSignal);
527
568
  try {
528
- await flushPending(rc);
569
+ if (!opts.child)
570
+ await flushPending(rc);
529
571
  if (!only && isPaused(rc.configDir))
530
572
  return 0;
531
573
  let job;
532
574
  try {
533
- job = await nextJob(rc, only, opts.workflow, { force: !!opts.workflow && !only });
575
+ const busy = only ? undefined : new Set(liveRuns(rc.configDir).filter((c) => c.pid !== process.pid).map((c) => workspaceOf(c)));
576
+ job = await nextJob(rc, only, opts.workflow, { force: !!opts.workflow && !only, schedule: opts.schedule, busy });
534
577
  }
535
578
  catch (err) {
536
579
  rc.log(`Could not read ${only} (${err.message}).`);
@@ -542,8 +585,8 @@ export async function tick(rc, only, opts = {}) {
542
585
  return only ? 1 : 0;
543
586
  }
544
587
  const workflow = rc.config.workflows[job.workflow];
545
- const project = rc.config.projects[workflow.project];
546
- if (!project) {
588
+ const resolved = resolveProject(rc.config, workflow, job.card);
589
+ if (!resolved) {
547
590
  rc.log(`Workflow ${job.workflow} points at a missing project ${workflow.project}. Run vsf agents workflow edit to fix it.`);
548
591
  return 1;
549
592
  }
@@ -551,13 +594,109 @@ export async function tick(rc, only, opts = {}) {
551
594
  rc.log(`Workflow ${job.workflow} has no ${stepIdLabel(opts.from)} step.`);
552
595
  return 2;
553
596
  }
554
- const outcome = await runCard(job.card, job.trigger, job.workflow, workflow, project, rc, { from: opts.from });
597
+ const busy = acquireWorkspace(rc.configDir, job.workspace, { pid: process.pid, card: job.card.identifier, startedAt: new Date().toISOString() });
598
+ if (busy) {
599
+ rc.log(`Workspace ${job.workspace} is busy with ${busy.card ?? 'a check'}. Try again when it finishes.`);
600
+ return 1;
601
+ }
602
+ held = job.workspace;
603
+ const outcome = await runCard(job.card, job.trigger, job.workflow, workflow, resolved.project, rc, { from: opts.from });
555
604
  return outcome === 'verified' ? 0 : 1;
556
605
  }
557
606
  catch (err) {
558
607
  rc.log(`Error: ${err.message}`);
559
608
  return 1;
560
609
  }
610
+ finally {
611
+ process.off('SIGTERM', onSignal);
612
+ process.off('SIGINT', onSignal);
613
+ if (held)
614
+ releaseWorkspace(rc.configDir, held);
615
+ }
616
+ }
617
+ // The arguments that make a child tick run exactly this job.
618
+ export const jobArgs = (job) => isCardTrigger(job.trigger) ? ['--card', job.card.identifier, '--workflow', job.workflow] : ['--workflow', job.workflow, '--schedule', job.trigger.schedule];
619
+ const realDeps = (rc) => ({
620
+ start: (args) => {
621
+ const [bin, ...rest] = rc.selfCommand;
622
+ return spawn(bin, [...rest, 'agents', 'tick', ...args], { stdio: 'inherit' });
623
+ },
624
+ waitMs: 60_000,
625
+ });
626
+ // The background runner. The service starts a new pass only once the last one
627
+ // ended, so this pass stays up while cards run: it starts one child process
628
+ // per workspace with work waiting, looks again whenever one ends or a minute
629
+ // passes, and returns once nothing runs and nothing waits. runner.lock keeps
630
+ // two dispatchers from working at once.
631
+ export async function dispatch(rc, over = {}) {
632
+ const deps = { ...realDeps(rc), ...over };
633
+ const held = readCurrent(rc.configDir);
634
+ if (held && heldByOther(held))
635
+ return 0;
636
+ writeCurrent(rc.configDir, { pid: process.pid, startedAt: new Date().toISOString() });
637
+ const children = new Map();
638
+ // When each card was started, so one whose run failed before it took the
639
+ // trigger label off waits for the next pass instead of starting again at once.
640
+ const started = new Map();
641
+ // Ends the wait early when a child exits.
642
+ let wake = () => { };
643
+ // A stop ends every run: each child cancels its own card.
644
+ let stopping = false;
645
+ const onSignal = async () => {
646
+ if (stopping)
647
+ return;
648
+ stopping = true;
649
+ const exits = [...children.values()].filter((c) => c.exitCode === null).map((c) => new Promise((resolve) => c.once('exit', () => resolve())));
650
+ for (const c of children.values())
651
+ c.kill('SIGTERM');
652
+ await Promise.race([Promise.all(exits), new Promise((resolve) => setTimeout(resolve, 10_000).unref())]);
653
+ clearCurrent(rc.configDir);
654
+ process.exit(143);
655
+ };
656
+ process.on('SIGTERM', onSignal);
657
+ process.on('SIGINT', onSignal);
658
+ try {
659
+ for (;;) {
660
+ if (deps.reload)
661
+ rc = { ...rc, config: deps.reload() };
662
+ await flushPending(rc);
663
+ if (!isPaused(rc.configDir)) {
664
+ const busy = new Set([...children.keys(), ...liveRuns(rc.configDir).map((c) => workspaceOf(c))]);
665
+ 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 })) {
667
+ started.set(job.card.identifier, Date.now());
668
+ rc.log(`${job.card.identifier} starts ${job.workflow} in workspace ${job.workspace}.`);
669
+ const child = deps.start(jobArgs(job));
670
+ children.set(job.workspace, child);
671
+ const ended = () => {
672
+ if (children.get(job.workspace) === child)
673
+ children.delete(job.workspace);
674
+ wake();
675
+ };
676
+ child.once('exit', ended);
677
+ // A program that could not start never exits.
678
+ child.once('error', (err) => {
679
+ rc.log(`Could not start the run for ${job.card.identifier} (${err?.message ?? 'unknown error'}).`);
680
+ ended();
681
+ });
682
+ }
683
+ }
684
+ if (!children.size)
685
+ return 0;
686
+ await new Promise((resolve) => {
687
+ const timer = setTimeout(resolve, deps.waitMs);
688
+ wake = () => {
689
+ clearTimeout(timer);
690
+ resolve();
691
+ };
692
+ });
693
+ wake = () => { };
694
+ }
695
+ }
696
+ catch (err) {
697
+ rc.log(`Error: ${err.message}`);
698
+ return 1;
699
+ }
561
700
  finally {
562
701
  process.off('SIGTERM', onSignal);
563
702
  process.off('SIGINT', onSignal);
package/dist/state.d.ts CHANGED
@@ -9,15 +9,24 @@ export interface Current {
9
9
  startedAt?: string;
10
10
  runDir?: string;
11
11
  source?: Trigger['source'];
12
+ workspace?: string;
12
13
  }
13
14
  export declare const lockPath: (configDir: string) => string;
14
- export declare function readCurrent(configDir: string): Current | null;
15
+ export declare const readCurrent: (configDir: string) => Current | null;
15
16
  export declare function writeCurrent(configDir: string, current: Current): void;
16
17
  export declare const clearCurrent: (configDir: string) => void;
17
18
  export declare function alive(pid: number): boolean;
18
19
  export declare function ours(pid: number): boolean;
19
20
  export declare function bootTime(): Date | null;
20
21
  export declare function liveCurrent(configDir: string): Current | null;
22
+ export declare function heldByOther(c: Current): boolean;
23
+ export declare const workspaceLockPath: (configDir: string, workspace: string) => string;
24
+ export declare function acquireWorkspace(configDir: string, workspace: string, current: Omit<Current, 'workspace'>): Current | null;
25
+ export declare function writeWorkspace(configDir: string, workspace: string, current: Omit<Current, 'workspace'>): void;
26
+ export declare const releaseWorkspace: (configDir: string, workspace: string) => void;
27
+ export declare function workspaceLocks(configDir: string): Current[];
28
+ export declare const liveRuns: (configDir: string) => Current[];
29
+ export declare const clearLock: (configDir: string, c: Current) => void;
21
30
  export declare const isPaused: (configDir: string) => boolean;
22
31
  export declare function setPaused(configDir: string, paused: boolean): void;
23
32
  export interface Pending {
@@ -51,6 +60,7 @@ export interface RunRecord {
51
60
  outcome: Outcome;
52
61
  reason?: string;
53
62
  prUrl?: string;
63
+ workspace?: string;
54
64
  startedAt: string;
55
65
  endedAt?: string;
56
66
  }
package/dist/state.js CHANGED
@@ -3,8 +3,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
3
3
  import { join } from 'node:path';
4
4
  import { agentsDir } from "./config.js";
5
5
  export const lockPath = (configDir) => join(agentsDir(configDir), 'runner.lock');
6
- export function readCurrent(configDir) {
7
- const path = lockPath(configDir);
6
+ function readLock(path) {
8
7
  if (!existsSync(path))
9
8
  return null;
10
9
  const text = readFileSync(path, 'utf8').trim();
@@ -16,6 +15,7 @@ export function readCurrent(configDir) {
16
15
  return null;
17
16
  }
18
17
  }
18
+ export const readCurrent = (configDir) => readLock(lockPath(configDir));
19
19
  export function writeCurrent(configDir, current) {
20
20
  mkdirSync(agentsDir(configDir), { recursive: true });
21
21
  writeFileSync(lockPath(configDir), JSON.stringify(current) + '\n');
@@ -44,11 +44,65 @@ export function bootTime() {
44
44
  const sec = /sec = (\d+)/.exec(r.stdout ?? '')?.[1];
45
45
  return sec ? new Date(Number(sec) * 1000) : null;
46
46
  }
47
- // The live run, or null when nothing runs or the lock is stale.
47
+ // The live dispatcher, or null when none runs or the lock is stale.
48
48
  export function liveCurrent(configDir) {
49
49
  const c = readCurrent(configDir);
50
50
  return c && ours(c.pid) ? c : null;
51
51
  }
52
+ // Whether a lock belongs to a live runner other than this process. A lock from
53
+ // before the last boot cannot be a live run, whatever its pid is now.
54
+ export function heldByOther(c) {
55
+ const booted = bootTime();
56
+ const fromBeforeBoot = !!(c.startedAt && booted && new Date(c.startedAt) < booted);
57
+ return c.pid !== process.pid && !fromBeforeBoot && ours(c.pid);
58
+ }
59
+ // One lock per workspace: the run holding its queue.
60
+ const locksDir = (configDir) => join(agentsDir(configDir), 'locks');
61
+ export const workspaceLockPath = (configDir, workspace) => join(locksDir(configDir), `${encodeURIComponent(workspace)}.json`);
62
+ // Takes a workspace's queue for this process. Returns null once taken, or the
63
+ // live run that holds it. A stale lock is removed and the take tried again.
64
+ // The file is created exclusively, so two runners never both take it.
65
+ export function acquireWorkspace(configDir, workspace, current) {
66
+ mkdirSync(locksDir(configDir), { recursive: true });
67
+ const path = workspaceLockPath(configDir, workspace);
68
+ for (let attempt = 0;; attempt++) {
69
+ try {
70
+ writeFileSync(path, JSON.stringify({ ...current, workspace }) + '\n', { flag: 'wx' });
71
+ return null;
72
+ }
73
+ catch (err) {
74
+ if (err.code !== 'EEXIST')
75
+ throw err;
76
+ }
77
+ const held = readLock(path);
78
+ if (held && heldByOther(held))
79
+ return held;
80
+ if (attempt)
81
+ return held ?? { pid: 0, workspace };
82
+ rmSync(path, { force: true });
83
+ }
84
+ }
85
+ // Updates the lock of a queue this process holds.
86
+ export function writeWorkspace(configDir, workspace, current) {
87
+ mkdirSync(locksDir(configDir), { recursive: true });
88
+ writeFileSync(workspaceLockPath(configDir, workspace), JSON.stringify({ ...current, workspace }) + '\n');
89
+ }
90
+ export const releaseWorkspace = (configDir, workspace) => rmSync(workspaceLockPath(configDir, workspace), { force: true });
91
+ // Every workspace lock on disk, stale ones included.
92
+ export function workspaceLocks(configDir) {
93
+ let names;
94
+ try {
95
+ names = readdirSync(locksDir(configDir)).filter((n) => n.endsWith('.json')).sort();
96
+ }
97
+ catch {
98
+ return [];
99
+ }
100
+ return names.map((n) => readLock(join(locksDir(configDir), n))).filter((c) => !!c);
101
+ }
102
+ // The card runs going on now, one per busy workspace.
103
+ export const liveRuns = (configDir) => workspaceLocks(configDir).filter((c) => ours(c.pid));
104
+ // Releases the lock a run left behind, wherever it lives.
105
+ export const clearLock = (configDir, c) => (c.workspace ? releaseWorkspace(configDir, c.workspace) : clearCurrent(configDir));
52
106
  // Paused: the runner stays loaded but picks up no new cards.
53
107
  const pausePath = (configDir) => join(agentsDir(configDir), 'paused');
54
108
  export const isPaused = (configDir) => existsSync(pausePath(configDir));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidlabs.gg/voidshift-agents",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Agents for Claude Code and Codex that plan, build and QA Linear cards, chained and run in the background.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {