@voidlabs.gg/voidshift-agents 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/dist/config.d.ts +19 -0
- package/dist/config.js +43 -0
- package/dist/control.d.ts +14 -2
- package/dist/control.js +91 -48
- package/dist/index.js +132 -10
- package/dist/manage.d.ts +2 -1
- package/dist/manage.js +46 -1
- package/dist/runner.d.ts +25 -2
- package/dist/runner.js +192 -56
- package/dist/state.d.ts +11 -1
- package/dist/state.js +57 -3
- package/dist/workflow.d.ts +4 -0
- package/dist/workflow.js +15 -0
- package/pack/vs-plan/SKILL.md +4 -1
- package/pack/vs-plan-review/SKILL.md +3 -1
- package/package.json +2 -2
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 {
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
export async function
|
|
372
|
-
const now =
|
|
373
|
-
const
|
|
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
|
|
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
|
-
|
|
403
|
-
if (!last || due) {
|
|
412
|
+
if (!last) {
|
|
404
413
|
cron[key] = now.toISOString();
|
|
405
414
|
cronChanged = true;
|
|
406
415
|
}
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
562
|
+
if (held)
|
|
563
|
+
releaseWorkspace(rc.configDir, held);
|
|
526
564
|
process.exit(143);
|
|
527
565
|
};
|
|
528
|
-
process.
|
|
529
|
-
process.
|
|
566
|
+
process.on('SIGTERM', onSignal);
|
|
567
|
+
process.on('SIGINT', onSignal);
|
|
530
568
|
try {
|
|
531
|
-
|
|
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
|
-
|
|
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
|
|
549
|
-
if (!
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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/dist/workflow.d.ts
CHANGED
|
@@ -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();
|
package/pack/vs-plan/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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.
|
|
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": {
|