@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/README.md +16 -2
- package/dist/config.d.ts +19 -0
- package/dist/config.js +43 -0
- package/dist/control.d.ts +7 -2
- package/dist/control.js +82 -38
- package/dist/index.js +186 -11
- package/dist/manage.d.ts +2 -1
- package/dist/manage.js +46 -1
- package/dist/notify.d.ts +30 -0
- package/dist/notify.js +233 -0
- package/dist/runner.d.ts +28 -2
- package/dist/runner.js +223 -60
- package/dist/secrets.d.ts +1 -0
- package/dist/secrets.js +12 -1
- package/dist/state.d.ts +11 -1
- package/dist/state.js +57 -3
- package/package.json +1 -1
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 {
|
|
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) => {
|
|
@@ -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
|
|
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 (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
export async function
|
|
369
|
-
const now =
|
|
370
|
-
const
|
|
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
|
|
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
|
-
|
|
400
|
-
if (!last || due) {
|
|
427
|
+
if (!last) {
|
|
401
428
|
cron[key] = now.toISOString();
|
|
402
429
|
cronChanged = true;
|
|
403
430
|
}
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
577
|
+
if (held)
|
|
578
|
+
releaseWorkspace(rc.configDir, held);
|
|
523
579
|
process.exit(143);
|
|
524
580
|
};
|
|
525
|
-
process.
|
|
526
|
-
process.
|
|
581
|
+
process.on('SIGTERM', onSignal);
|
|
582
|
+
process.on('SIGINT', onSignal);
|
|
527
583
|
try {
|
|
528
|
-
|
|
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
|
-
|
|
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
|
|
546
|
-
if (!
|
|
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
|
|
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-
|
|
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
|
|
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/package.json
CHANGED