@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/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
- import { LABELS, PLAN_HEADING, buildPrompt, findingsFor, fixTarget, isStatusLabel, latestPlan, parseDevResult, parseVerdict, planBody, qaPassed, stageLabels, verdictBlock, } from "./workflow.js";
4
- import { agentsDir, hasWork, isCardTrigger, resolveStep, stepId, stepIdLabel } from "./config.js";
4
+ import { LABELS, PLAN_HEADING, buildPrompt, findingsFor, fixTarget, isStatusLabel, latestPlan, needsPlan, parseDevResult, parseVerdict, planBody, qaPassed, stageLabels, verdictBlock, } from "./workflow.js";
5
+ import { agentsDir, hasWork, isCardTrigger, resolveProject, resolveStep, stepId, stepIdLabel, workspaceContext, workspaceOf } from "./config.js";
5
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) => {
@@ -138,10 +140,7 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
138
140
  throw new Park(`Workflow ${workflowName} has no ${stepIdLabel(opts.from)} step.`);
139
141
  // A scheduled run has no card to plan on, so its task is the plan unless a Plan step writes one.
140
142
  let plan = isCardTrigger(trigger) ? latestPlan(await linear.comments(card.identifier)) : card.description ?? null;
141
- const firstPlan = steps.findIndex((d, n) => n >= from && d.output === 'plan');
142
- // A check before any commits is a check on the plan, like Plan review.
143
- const needsPlan = steps.findIndex((d, n) => n >= from && (d.output === 'commits' || (d.output === 'verdict' && d.builtin !== 'vs-qa')));
144
- if (!plan && needsPlan >= 0 && (firstPlan < 0 || firstPlan > needsPlan)) {
143
+ if (!plan && needsPlan(steps, from)) {
145
144
  throw new Park('No plan comment (starting with "## Plan") was found. Publish a plan first, or start the workflow with Plan.');
146
145
  }
147
146
  await stage(stageOf(steps[from]));
@@ -160,7 +159,8 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
160
159
  const pulled = syncMemory(rc.selfCommand, rc.configDir);
161
160
  if (pulled)
162
161
  say(pulled);
163
- 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');
164
164
  const worktree = join(agentsDir(rc.configDir), 'worktrees', card.identifier);
165
165
  ensureWorktree(project.path, worktree, card.branchName, base);
166
166
  say(`working in ${worktree} on ${card.branchName}`);
@@ -346,7 +346,9 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
346
346
  say(shared);
347
347
  }
348
348
  }
349
- // 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);
350
352
  export function cronCard(workflow, task, at) {
351
353
  const stamp = at.toISOString().slice(0, 16).replace(/[-:T]/g, '');
352
354
  const slug = workflow.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'cron';
@@ -363,21 +365,24 @@ export function cronCard(workflow, task, at) {
363
365
  };
364
366
  }
365
367
  const cronKey = (workflow, schedule) => `${workflow} ${schedule}`;
366
- // The first job waiting on any workflow's triggers, in workflow order: a card
367
- // carrying a trigger label, or a schedule that came due since it last fired.
368
- // A schedule seen for the first time starts counting from now. With a card,
369
- // only that card is considered; with a workflow name, only that workflow, and
370
- // `force` fires its schedule now.
371
- export async function nextJob(rc, only, workflowName, opts = {}) {
372
- const now = opts.now ?? new Date();
373
- 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);
374
379
  let pinned = null;
375
- if (only) {
376
- const team = only.split('-')[0].toUpperCase();
380
+ if (q.only) {
381
+ const team = q.only.split('-')[0].toUpperCase();
377
382
  const sources = [...new Set(workflows.flatMap(([, w]) => w.triggers.filter(isCardTrigger).filter((t) => t.team === team).map((t) => t.source)))];
378
383
  for (const source of sources) {
379
384
  try {
380
- pinned = { card: await rc.trackers(source).card(only), source };
385
+ pinned = { card: await rc.trackers(source).card(q.only), source };
381
386
  break;
382
387
  }
383
388
  catch (err) {
@@ -386,32 +391,50 @@ export async function nextJob(rc, only, workflowName, opts = {}) {
386
391
  }
387
392
  }
388
393
  if (!pinned)
389
- return null;
394
+ return [];
390
395
  }
391
396
  const cron = readCronState(rc.configDir);
392
397
  let cronChanged = false;
398
+ const take = (job, workspace) => {
399
+ taken.add(workspace);
400
+ out.push({ ...job, workspace });
401
+ return out.length >= limit;
402
+ };
393
403
  try {
394
404
  for (const [name, workflow] of workflows) {
395
405
  for (const trigger of workflow.triggers) {
396
406
  if (!isCardTrigger(trigger)) {
397
- const schedule = only ? null : parseCron(trigger.schedule);
407
+ const schedule = q.only || (q.schedule && q.schedule !== trigger.schedule) ? null : parseCron(trigger.schedule);
398
408
  if (!schedule)
399
409
  continue;
400
410
  const key = cronKey(name, trigger.schedule);
401
411
  const last = cron[key];
402
- const due = opts.force ? now : last ? lastDue(schedule, new Date(last), now) : null;
403
- if (!last || due) {
412
+ if (!last) {
404
413
  cron[key] = now.toISOString();
405
414
  cronChanged = true;
406
415
  }
407
- if (due)
408
- 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;
409
427
  continue;
410
428
  }
429
+ // A run for one schedule takes no labelled cards.
430
+ if (q.schedule)
431
+ continue;
411
432
  // A pinned card may have lost its label (a parked card re-run by hand), so match by team.
412
433
  if (pinned) {
413
- if (pinned.source === trigger.source && pinned.card.teamKey === trigger.team)
414
- 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
+ }
415
438
  continue;
416
439
  }
417
440
  let cards;
@@ -423,18 +446,33 @@ export async function nextJob(rc, only, workflowName, opts = {}) {
423
446
  rc.log(`Could not check ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} for ${trigger.team} + ${trigger.label} (${err.message}). Trying again next minute.`);
424
447
  continue;
425
448
  }
426
- const card = cards.find((c) => c.teamKey === trigger.team && c.labels.includes(trigger.label));
427
- if (card)
428
- 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
+ }
429
461
  }
430
462
  }
431
- return null;
463
+ return out;
432
464
  }
433
465
  finally {
434
466
  if (cronChanged)
435
467
  writeCronState(rc.configDir, cron);
436
468
  }
437
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
+ }
438
476
  // Retries tracker writes that failed while parking. Never throws.
439
477
  async function flushPending(rc) {
440
478
  const left = [];
@@ -485,21 +523,20 @@ function queueCancel(configDir, cardId, comment, source = 'linear') {
485
523
  // nothing more to do
486
524
  }
487
525
  }
488
- // 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.
489
530
  export async function tick(rc, only, opts = {}) {
490
- const held = readCurrent(rc.configDir);
491
- const booted = bootTime();
492
- // A lock from before the last boot cannot be a live run, whatever its pid is now.
493
- const fromBeforeBoot = !!(held?.startedAt && booted && new Date(held.startedAt) < booted);
494
- if (held && held.pid !== process.pid && !fromBeforeBoot && ours(held.pid)) {
495
- if (only)
496
- rc.log(`The runner is busy with ${held.card ?? 'a check'}. Try again when it finishes.`);
497
- return only ? 1 : 0;
498
- }
499
- writeCurrent(rc.configDir, { pid: process.pid, startedAt: new Date().toISOString() });
500
- // A stop (Ctrl+C, launchd, shutdown) ends the agents, marks the run, and
501
- // 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;
502
536
  const onSignal = async () => {
537
+ if (stopping)
538
+ return;
539
+ stopping = true;
503
540
  await stopAgents();
504
541
  const running = active && active.record.outcome === 'running' ? active : null;
505
542
  if (active) {
@@ -522,18 +559,21 @@ export async function tick(rc, only, opts = {}) {
522
559
  if (!finished)
523
560
  queueCancel(rc.configDir, card, `**Cancelled.** Add \`${trigger}\` again to restart.`, source);
524
561
  }
525
- clearCurrent(rc.configDir);
562
+ if (held)
563
+ releaseWorkspace(rc.configDir, held);
526
564
  process.exit(143);
527
565
  };
528
- process.once('SIGTERM', onSignal);
529
- process.once('SIGINT', onSignal);
566
+ process.on('SIGTERM', onSignal);
567
+ process.on('SIGINT', onSignal);
530
568
  try {
531
- await flushPending(rc);
569
+ if (!opts.child)
570
+ await flushPending(rc);
532
571
  if (!only && isPaused(rc.configDir))
533
572
  return 0;
534
573
  let job;
535
574
  try {
536
- 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 });
537
577
  }
538
578
  catch (err) {
539
579
  rc.log(`Could not read ${only} (${err.message}).`);
@@ -545,8 +585,8 @@ export async function tick(rc, only, opts = {}) {
545
585
  return only ? 1 : 0;
546
586
  }
547
587
  const workflow = rc.config.workflows[job.workflow];
548
- const project = rc.config.projects[workflow.project];
549
- if (!project) {
588
+ const resolved = resolveProject(rc.config, workflow, job.card);
589
+ if (!resolved) {
550
590
  rc.log(`Workflow ${job.workflow} points at a missing project ${workflow.project}. Run vsf agents workflow edit to fix it.`);
551
591
  return 1;
552
592
  }
@@ -554,13 +594,109 @@ export async function tick(rc, only, opts = {}) {
554
594
  rc.log(`Workflow ${job.workflow} has no ${stepIdLabel(opts.from)} step.`);
555
595
  return 2;
556
596
  }
557
- 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 });
558
604
  return outcome === 'verified' ? 0 : 1;
559
605
  }
560
606
  catch (err) {
561
607
  rc.log(`Error: ${err.message}`);
562
608
  return 1;
563
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
+ }
564
700
  finally {
565
701
  process.off('SIGTERM', onSignal);
566
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));
@@ -49,4 +49,8 @@ export interface PromptInput {
49
49
  export declare function buildPrompt(p: PromptInput): string;
50
50
  export declare function verdictBlock<T>(steps: T[], i: number, isVerdict: (s: T) => boolean): [number, number];
51
51
  export declare function fixTarget<T>(steps: T[], blockStart: number, isProducer: (s: T) => boolean): number;
52
+ export declare function needsPlan(steps: {
53
+ output: string;
54
+ builtin?: string;
55
+ }[], from: number): boolean;
52
56
  export declare function planBody(output: string): string;
package/dist/workflow.js CHANGED
@@ -105,6 +105,21 @@ export function fixTarget(steps, blockStart, isProducer) {
105
105
  return j;
106
106
  return -1;
107
107
  }
108
+ // Whether a run starting at `from` needs a plan comment: some step before any
109
+ // Plan step writes commits or checks the plan. A check counts as a plan check
110
+ // only when a Plan step comes before it, so a review-only workflow runs bare.
111
+ export function needsPlan(steps, from) {
112
+ for (let n = from; n < steps.length; n++) {
113
+ const s = steps[n];
114
+ if (s.output === 'plan')
115
+ return false;
116
+ if (s.output === 'commits')
117
+ return true;
118
+ if (s.output === 'verdict' && s.builtin !== 'vs-qa' && (s.builtin === 'vs-plan-review' || steps.slice(0, n).some((d) => d.output === 'plan')))
119
+ return true;
120
+ }
121
+ return false;
122
+ }
108
123
  // Plan output minus the trailing RESULT line, ready to post.
109
124
  export function planBody(output) {
110
125
  return output.replace(/^\s*RESULT:.*$/gim, '').trim();
@@ -94,7 +94,9 @@ The plan file holds the detail. In the chat, keep it short and plain:
94
94
  Start straight at Understanding. Do not repeat the card id or title; the card
95
95
  already shows them.
96
96
 
97
- Length follows the change: under 350 words for a small one, never padded.
97
+ Outcome first. Under 350 words for a small change, never over 700 for any
98
+ change. File and line detail only in Surfaces; no code blocks over 5 lines;
99
+ Six angles lists only angles that apply.
98
100
 
99
101
  **Understanding.** Two to four sentences: what the card asks, what it got
100
102
  wrong, which reading you chose.
@@ -123,6 +125,7 @@ anything a human must click.
123
125
  - No ticket ids in code comments.
124
126
  - No em dashes or en dashes.
125
127
  - No narration of process. State findings.
128
+ - Do not quote code the dev agent can read.
126
129
 
127
130
  ## When you run inside a workflow
128
131
 
@@ -41,7 +41,8 @@ main points.
41
41
  2. The findings that change what gets built, most important first, at most 5,
42
42
  one short plain line each: what is wrong and what to do about it.
43
43
  3. Everything smaller in one line: `Minor: ...`.
44
- 4. A short `Details` section: for each finding, the file and line that proves it.
44
+ 4. A short `Details` section: one line per finding, the file and line that
45
+ proves it.
45
46
  5. Last line, exactly one of:
46
47
 
47
48
  ```
@@ -56,6 +57,7 @@ fixes to the plan file.
56
57
 
57
58
  - No em dashes or en dashes.
58
59
  - No narration of process. State findings.
60
+ - Whole review under 300 words.
59
61
 
60
62
  ## When you run inside a workflow
61
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidlabs.gg/voidshift-agents",
3
- "version": "0.5.0",
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": {
@@ -40,7 +40,7 @@
40
40
  "scripts": {
41
41
  "build": "rm -rf dist && tsc -p tsconfig.json",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit",
43
- "test": "node --test test/*.test.ts",
43
+ "test": "node --test --test-force-exit --test-timeout=60000 test/*.test.ts",
44
44
  "prepublishOnly": "rm -rf dist && tsc -p tsconfig.json"
45
45
  },
46
46
  "devDependencies": {