@voidlabs.gg/voidshift-agents 0.5.1 → 0.7.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
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) => {
@@ -109,7 +111,17 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
109
111
  await linear.comment(card, `${PLAN_HEADING}\n\n${planToPost}`);
110
112
  planToPost = null;
111
113
  };
112
- async function park(reason) {
114
+ const ping = async (kind, reason) => {
115
+ if (!rc.notify)
116
+ return;
117
+ try {
118
+ await rc.notify({ kind, card: card.identifier, title: card.title, workflow: workflowName, url: card.url || undefined, prUrl: record.prUrl, reason });
119
+ }
120
+ catch (err) {
121
+ say(`discord: ${err.message}`);
122
+ }
123
+ };
124
+ async function park(reason, kind = 'parked') {
113
125
  say(`parked: ${reason}`);
114
126
  const comment = `**Parked:** ${reason}${lastFindings ? `\n\n${lastFindings}` : ''}\n\nFix the cause, then add \`${label}\` again to retry.`;
115
127
  const add = labelsOn ? [LABELS.parked, ...(loops ? [LABELS.version(loops)] : [])] : [];
@@ -121,13 +133,15 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
121
133
  await endLabels(LABELS.parked, loops || undefined);
122
134
  }
123
135
  catch (err) {
124
- if (!isCardTrigger(trigger))
125
- return done('parked', reason);
126
- say(`could not reach ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} to park it (${err.message}). Will retry next minute.`);
127
- // An empty comment is skipped on retry, so it is not posted twice.
128
- writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [label], add, dropStatus: labelsOn, source: trigger.source }]);
136
+ if (isCardTrigger(trigger)) {
137
+ say(`could not reach ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} to park it (${err.message}). Will retry next minute.`);
138
+ // An empty comment is skipped on retry, so it is not posted twice.
139
+ writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [label], add, dropStatus: labelsOn, source: trigger.source }]);
140
+ }
129
141
  }
130
- return done('parked', reason);
142
+ const outcome = done('parked', reason);
143
+ await ping(kind, reason);
144
+ return outcome;
131
145
  }
132
146
  try {
133
147
  if (!existsSync(join(project.path, '.git')))
@@ -157,7 +171,8 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
157
171
  const pulled = syncMemory(rc.selfCommand, rc.configDir);
158
172
  if (pulled)
159
173
  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');
174
+ const context = workspaceContext(config, workspace);
175
+ 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
176
  const worktree = join(agentsDir(rc.configDir), 'worktrees', card.identifier);
162
177
  ensureWorktree(project.path, worktree, card.branchName, base);
163
178
  say(`working in ${worktree} on ${card.branchName}`);
@@ -328,12 +343,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
328
343
  await linear.comment(card, `**All checks passed.**${prUrl ? ` PR ready: ${prUrl}` : ''}${!moved ? `\n\n(No "${state}" state on this team, so the card was not moved.)` : ''}`);
329
344
  }
330
345
  say('verified');
331
- return done('verified');
346
+ done('verified');
332
347
  }
333
348
  catch (err) {
334
349
  if (err instanceof Park)
335
350
  return park(err.message);
336
- return park(`Something went wrong: ${err.message}`);
351
+ return park(`Something went wrong: ${err.message}`, 'failed');
337
352
  }
338
353
  finally {
339
354
  active = null;
@@ -342,8 +357,13 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
342
357
  if (shared)
343
358
  say(shared);
344
359
  }
360
+ // Outside the try, so a ping that throws can never park a verified run.
361
+ await ping(record.prUrl ? 'ready' : 'passed');
362
+ return 'verified';
345
363
  }
346
- // A scheduled run's stand-in for a card: the task, on a branch of its own.
364
+ // The workspace whose queue a job waits in. A workflow whose project is
365
+ // missing still queues somewhere, so its run can say what is wrong.
366
+ const jobWorkspace = (config, workflow, card) => workspaceOf(resolveProject(config, workflow, card)?.project);
347
367
  export function cronCard(workflow, task, at) {
348
368
  const stamp = at.toISOString().slice(0, 16).replace(/[-:T]/g, '');
349
369
  const slug = workflow.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'cron';
@@ -360,21 +380,24 @@ export function cronCard(workflow, task, at) {
360
380
  };
361
381
  }
362
382
  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);
383
+ // Jobs waiting on the workflows' triggers, in workflow order, at most one per
384
+ // workspace and none in a busy one: a card carrying a trigger label, or a
385
+ // schedule that came due since it last fired. A schedule seen for the first
386
+ // time starts counting from now, and one whose workspace is busy keeps waiting
387
+ // without being marked as fired.
388
+ export async function findJobs(rc, q = {}) {
389
+ const now = q.now ?? new Date();
390
+ const limit = q.limit ?? Infinity;
391
+ const taken = new Set(q.busy ?? []);
392
+ const out = [];
393
+ const workflows = Object.entries(rc.config.workflows).filter(([name]) => !q.workflow || name === q.workflow);
371
394
  let pinned = null;
372
- if (only) {
373
- const team = only.split('-')[0].toUpperCase();
395
+ if (q.only) {
396
+ const team = q.only.split('-')[0].toUpperCase();
374
397
  const sources = [...new Set(workflows.flatMap(([, w]) => w.triggers.filter(isCardTrigger).filter((t) => t.team === team).map((t) => t.source)))];
375
398
  for (const source of sources) {
376
399
  try {
377
- pinned = { card: await rc.trackers(source).card(only), source };
400
+ pinned = { card: await rc.trackers(source).card(q.only), source };
378
401
  break;
379
402
  }
380
403
  catch (err) {
@@ -383,32 +406,50 @@ export async function nextJob(rc, only, workflowName, opts = {}) {
383
406
  }
384
407
  }
385
408
  if (!pinned)
386
- return null;
409
+ return [];
387
410
  }
388
411
  const cron = readCronState(rc.configDir);
389
412
  let cronChanged = false;
413
+ const take = (job, workspace) => {
414
+ taken.add(workspace);
415
+ out.push({ ...job, workspace });
416
+ return out.length >= limit;
417
+ };
390
418
  try {
391
419
  for (const [name, workflow] of workflows) {
392
420
  for (const trigger of workflow.triggers) {
393
421
  if (!isCardTrigger(trigger)) {
394
- const schedule = only ? null : parseCron(trigger.schedule);
422
+ const schedule = q.only || (q.schedule && q.schedule !== trigger.schedule) ? null : parseCron(trigger.schedule);
395
423
  if (!schedule)
396
424
  continue;
397
425
  const key = cronKey(name, trigger.schedule);
398
426
  const last = cron[key];
399
- const due = opts.force ? now : last ? lastDue(schedule, new Date(last), now) : null;
400
- if (!last || due) {
427
+ if (!last) {
401
428
  cron[key] = now.toISOString();
402
429
  cronChanged = true;
403
430
  }
404
- if (due)
405
- return { card: cronCard(name, trigger.task, due), trigger, workflow: name };
431
+ const due = q.force ? now : last ? lastDue(schedule, new Date(last), now) : null;
432
+ if (!due)
433
+ continue;
434
+ const card = cronCard(name, trigger.task, due);
435
+ const workspace = jobWorkspace(rc.config, workflow, card);
436
+ if (taken.has(workspace))
437
+ continue;
438
+ cron[key] = now.toISOString();
439
+ cronChanged = true;
440
+ if (take({ card, trigger, workflow: name }, workspace))
441
+ return out;
406
442
  continue;
407
443
  }
444
+ // A run for one schedule takes no labelled cards.
445
+ if (q.schedule)
446
+ continue;
408
447
  // A pinned card may have lost its label (a parked card re-run by hand), so match by team.
409
448
  if (pinned) {
410
- if (pinned.source === trigger.source && pinned.card.teamKey === trigger.team)
411
- return { card: pinned.card, trigger, workflow: name };
449
+ if (pinned.source === trigger.source && pinned.card.teamKey === trigger.team) {
450
+ take({ card: pinned.card, trigger, workflow: name }, jobWorkspace(rc.config, workflow, pinned.card));
451
+ return out;
452
+ }
412
453
  continue;
413
454
  }
414
455
  let cards;
@@ -420,18 +461,33 @@ export async function nextJob(rc, only, workflowName, opts = {}) {
420
461
  rc.log(`Could not check ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} for ${trigger.team} + ${trigger.label} (${err.message}). Trying again next minute.`);
421
462
  continue;
422
463
  }
423
- const card = cards.find((c) => c.teamKey === trigger.team && c.labels.includes(trigger.label));
424
- if (card)
425
- return { card, trigger, workflow: name };
464
+ for (const card of cards) {
465
+ if (card.teamKey !== trigger.team || !card.labels.includes(trigger.label))
466
+ continue;
467
+ // A card already taken this pass, by an earlier trigger, is not run twice.
468
+ if (q.skip?.has(card.identifier) || out.some((j) => j.card.identifier === card.identifier))
469
+ continue;
470
+ const workspace = jobWorkspace(rc.config, workflow, card);
471
+ if (taken.has(workspace))
472
+ continue;
473
+ if (take({ card, trigger, workflow: name }, workspace))
474
+ return out;
475
+ }
426
476
  }
427
477
  }
428
- return null;
478
+ return out;
429
479
  }
430
480
  finally {
431
481
  if (cronChanged)
432
482
  writeCronState(rc.configDir, cron);
433
483
  }
434
484
  }
485
+ // The first job waiting, as findJobs finds it. With a card, only that card is
486
+ // considered; with a workflow name, only that workflow, and `force` fires its
487
+ // schedule now.
488
+ export async function nextJob(rc, only, workflowName, opts = {}) {
489
+ return (await findJobs(rc, { ...opts, only, workflow: workflowName, limit: 1 }))[0] ?? null;
490
+ }
435
491
  // Retries tracker writes that failed while parking. Never throws.
436
492
  async function flushPending(rc) {
437
493
  const left = [];
@@ -482,21 +538,20 @@ function queueCancel(configDir, cardId, comment, source = 'linear') {
482
538
  // nothing more to do
483
539
  }
484
540
  }
485
- // One pass for the background runner. A lock keeps two passes from working at once.
541
+ // Runs one job in its workspace's queue: a pinned card, a workflow's schedule,
542
+ // or the first job waiting in a free workspace. The workspace lock keeps two
543
+ // runs from working in one workspace at once. `child` is a run the dispatcher
544
+ // started, which leaves the pending writes to it.
486
545
  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.
546
+ let held = null;
547
+ // A stop (Ctrl+C, the dispatcher, launchd, shutdown) ends the agents, marks
548
+ // the run, and takes the working label off the card so it is never left stuck.
549
+ // The dispatcher and the service can both signal, so a second one is ignored.
550
+ let stopping = false;
499
551
  const onSignal = async () => {
552
+ if (stopping)
553
+ return;
554
+ stopping = true;
500
555
  await stopAgents();
501
556
  const running = active && active.record.outcome === 'running' ? active : null;
502
557
  if (active) {
@@ -519,18 +574,21 @@ export async function tick(rc, only, opts = {}) {
519
574
  if (!finished)
520
575
  queueCancel(rc.configDir, card, `**Cancelled.** Add \`${trigger}\` again to restart.`, source);
521
576
  }
522
- clearCurrent(rc.configDir);
577
+ if (held)
578
+ releaseWorkspace(rc.configDir, held);
523
579
  process.exit(143);
524
580
  };
525
- process.once('SIGTERM', onSignal);
526
- process.once('SIGINT', onSignal);
581
+ process.on('SIGTERM', onSignal);
582
+ process.on('SIGINT', onSignal);
527
583
  try {
528
- await flushPending(rc);
584
+ if (!opts.child)
585
+ await flushPending(rc);
529
586
  if (!only && isPaused(rc.configDir))
530
587
  return 0;
531
588
  let job;
532
589
  try {
533
- job = await nextJob(rc, only, opts.workflow, { force: !!opts.workflow && !only });
590
+ const busy = only ? undefined : new Set(liveRuns(rc.configDir).filter((c) => c.pid !== process.pid).map((c) => workspaceOf(c)));
591
+ job = await nextJob(rc, only, opts.workflow, { force: !!opts.workflow && !only, schedule: opts.schedule, busy });
534
592
  }
535
593
  catch (err) {
536
594
  rc.log(`Could not read ${only} (${err.message}).`);
@@ -542,8 +600,8 @@ export async function tick(rc, only, opts = {}) {
542
600
  return only ? 1 : 0;
543
601
  }
544
602
  const workflow = rc.config.workflows[job.workflow];
545
- const project = rc.config.projects[workflow.project];
546
- if (!project) {
603
+ const resolved = resolveProject(rc.config, workflow, job.card);
604
+ if (!resolved) {
547
605
  rc.log(`Workflow ${job.workflow} points at a missing project ${workflow.project}. Run vsf agents workflow edit to fix it.`);
548
606
  return 1;
549
607
  }
@@ -551,13 +609,118 @@ export async function tick(rc, only, opts = {}) {
551
609
  rc.log(`Workflow ${job.workflow} has no ${stepIdLabel(opts.from)} step.`);
552
610
  return 2;
553
611
  }
554
- const outcome = await runCard(job.card, job.trigger, job.workflow, workflow, project, rc, { from: opts.from });
612
+ const busy = acquireWorkspace(rc.configDir, job.workspace, { pid: process.pid, card: job.card.identifier, startedAt: new Date().toISOString() });
613
+ if (busy) {
614
+ rc.log(`Workspace ${job.workspace} is busy with ${busy.card ?? 'a check'}. Try again when it finishes.`);
615
+ return 1;
616
+ }
617
+ held = job.workspace;
618
+ const outcome = await runCard(job.card, job.trigger, job.workflow, workflow, resolved.project, rc, { from: opts.from });
555
619
  return outcome === 'verified' ? 0 : 1;
556
620
  }
557
621
  catch (err) {
558
622
  rc.log(`Error: ${err.message}`);
559
623
  return 1;
560
624
  }
625
+ finally {
626
+ process.off('SIGTERM', onSignal);
627
+ process.off('SIGINT', onSignal);
628
+ if (held)
629
+ releaseWorkspace(rc.configDir, held);
630
+ }
631
+ }
632
+ // The arguments that make a child tick run exactly this job.
633
+ export const jobArgs = (job) => isCardTrigger(job.trigger) ? ['--card', job.card.identifier, '--workflow', job.workflow] : ['--workflow', job.workflow, '--schedule', job.trigger.schedule];
634
+ const realDeps = (rc) => ({
635
+ start: (args) => {
636
+ const [bin, ...rest] = rc.selfCommand;
637
+ return spawn(bin, [...rest, 'agents', 'tick', ...args], { stdio: 'inherit' });
638
+ },
639
+ waitMs: 60_000,
640
+ });
641
+ // The background runner. The service starts a new pass only once the last one
642
+ // ended, so this pass stays up while cards run: it starts one child process
643
+ // per workspace with work waiting, looks again whenever one ends or a minute
644
+ // passes, and returns once nothing runs and nothing waits. runner.lock keeps
645
+ // two dispatchers from working at once.
646
+ export async function dispatch(rc, over = {}) {
647
+ const deps = { ...realDeps(rc), ...over };
648
+ const held = readCurrent(rc.configDir);
649
+ if (held && heldByOther(held))
650
+ return 0;
651
+ writeCurrent(rc.configDir, { pid: process.pid, startedAt: new Date().toISOString() });
652
+ const children = new Map();
653
+ // When each card was started, so one whose run failed before it took the
654
+ // trigger label off waits for the next pass instead of starting again at once.
655
+ const started = new Map();
656
+ // Ends the wait early when a child exits.
657
+ let wake = () => { };
658
+ // A stop ends every run: each child cancels its own card.
659
+ let stopping = false;
660
+ const onSignal = async () => {
661
+ if (stopping)
662
+ return;
663
+ stopping = true;
664
+ const exits = [...children.values()].filter((c) => c.exitCode === null).map((c) => new Promise((resolve) => c.once('exit', () => resolve())));
665
+ for (const c of children.values())
666
+ c.kill('SIGTERM');
667
+ await Promise.race([Promise.all(exits), new Promise((resolve) => setTimeout(resolve, 10_000).unref())]);
668
+ clearCurrent(rc.configDir);
669
+ process.exit(143);
670
+ };
671
+ process.on('SIGTERM', onSignal);
672
+ process.on('SIGINT', onSignal);
673
+ try {
674
+ for (;;) {
675
+ if (deps.reload)
676
+ rc = { ...rc, config: deps.reload() };
677
+ await flushPending(rc);
678
+ const paused = isPaused(rc.configDir);
679
+ if (rc.summary) {
680
+ try {
681
+ await rc.summary(paused);
682
+ }
683
+ catch (err) {
684
+ rc.log(`discord: ${err.message}`);
685
+ }
686
+ }
687
+ if (!paused) {
688
+ const busy = new Set([...children.keys(), ...liveRuns(rc.configDir).map((c) => workspaceOf(c))]);
689
+ const skip = new Set([...started].filter(([, at]) => Date.now() - at < deps.waitMs).map(([id]) => id));
690
+ for (const job of await findJobs(rc, { busy, skip })) {
691
+ started.set(job.card.identifier, Date.now());
692
+ rc.log(`${job.card.identifier} starts ${job.workflow} in workspace ${job.workspace}.`);
693
+ const child = deps.start(jobArgs(job));
694
+ children.set(job.workspace, child);
695
+ const ended = () => {
696
+ if (children.get(job.workspace) === child)
697
+ children.delete(job.workspace);
698
+ wake();
699
+ };
700
+ child.once('exit', ended);
701
+ // A program that could not start never exits.
702
+ child.once('error', (err) => {
703
+ rc.log(`Could not start the run for ${job.card.identifier} (${err?.message ?? 'unknown error'}).`);
704
+ ended();
705
+ });
706
+ }
707
+ }
708
+ if (!children.size)
709
+ return 0;
710
+ await new Promise((resolve) => {
711
+ const timer = setTimeout(resolve, deps.waitMs);
712
+ wake = () => {
713
+ clearTimeout(timer);
714
+ resolve();
715
+ };
716
+ });
717
+ wake = () => { };
718
+ }
719
+ }
720
+ catch (err) {
721
+ rc.log(`Error: ${err.message}`);
722
+ return 1;
723
+ }
561
724
  finally {
562
725
  process.off('SIGTERM', onSignal);
563
726
  process.off('SIGINT', onSignal);
package/dist/secrets.d.ts CHANGED
@@ -5,5 +5,6 @@ export declare function readSecretsFile(path: string): Record<string, string>;
5
5
  export declare function writeSecretsFile(path: string, values: Record<string, string>): void;
6
6
  export declare function readSecret(configDir: string, name: string, platform?: NodeJS.Platform): string | null;
7
7
  export declare function storeSecret(configDir: string, name: string, value: string, platform?: NodeJS.Platform): boolean;
8
+ export declare function removeSecret(configDir: string, name: string, platform?: NodeJS.Platform): boolean;
8
9
  export declare const readLinearKey: (configDir: string) => string | null;
9
10
  export declare const storeLinearKey: (configDir: string, key: string) => boolean;
package/dist/secrets.js CHANGED
@@ -40,7 +40,7 @@ export function readSecret(configDir, name, platform = process.platform) {
40
40
  return readSecretsFile(secretsPath(configDir))[name] || null;
41
41
  }
42
42
  export function storeSecret(configDir, name, value, platform = process.platform) {
43
- if (!SAFE_VALUE.test(value) || !/^[a-z-]+$/.test(name))
43
+ if (!SAFE_VALUE.test(value) || !/^[a-z0-9-]+$/.test(name))
44
44
  return false;
45
45
  if (platform === 'darwin') {
46
46
  // On stdin through `security -i`, so the value never appears in a process list.
@@ -51,5 +51,16 @@ export function storeSecret(configDir, name, value, platform = process.platform)
51
51
  writeSecretsFile(path, { ...readSecretsFile(path), [name]: value });
52
52
  return true;
53
53
  }
54
+ export function removeSecret(configDir, name, platform = process.platform) {
55
+ if (platform === 'darwin') {
56
+ // 44 is the Keychain's "not found": already gone counts as removed.
57
+ const status = spawnSync('/usr/bin/security', ['delete-generic-password', '-s', service(name)], { encoding: 'utf8' }).status;
58
+ return status === 0 || status === 44;
59
+ }
60
+ const path = secretsPath(configDir);
61
+ const { [name]: _gone, ...rest } = readSecretsFile(path);
62
+ writeSecretsFile(path, rest);
63
+ return true;
64
+ }
54
65
  export const readLinearKey = (configDir) => readSecret(configDir, 'linear');
55
66
  export const storeLinearKey = (configDir, key) => looksLikeLinearKey(key) && storeSecret(configDir, 'linear', key);
package/dist/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.7.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": {