@voidlabs.gg/voidshift-agents 0.6.0 → 0.8.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 +31 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +11 -0
- package/dist/control.d.ts +20 -0
- package/dist/control.js +103 -23
- package/dist/engine.d.ts +22 -0
- package/dist/engine.js +144 -7
- package/dist/git.d.ts +1 -0
- package/dist/git.js +5 -0
- package/dist/index.js +54 -1
- package/dist/manage.d.ts +2 -2
- package/dist/manage.js +21 -5
- package/dist/notify.d.ts +30 -0
- package/dist/notify.js +233 -0
- package/dist/runner.d.ts +8 -1
- package/dist/runner.js +245 -46
- package/dist/secrets.d.ts +1 -0
- package/dist/secrets.js +12 -1
- package/dist/service.js +1 -1
- package/dist/state.d.ts +40 -1
- package/dist/state.js +116 -0
- package/dist/systemd.d.ts +2 -0
- package/dist/systemd.js +14 -0
- package/dist/usage.d.ts +18 -0
- package/dist/usage.js +102 -0
- package/package.json +1 -1
package/dist/runner.js
CHANGED
|
@@ -2,16 +2,22 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
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
|
+
import { DEFAULTS, agentsDir, hasWork, isCardTrigger, resolveProject, resolveStep, stepId, stepIdLabel, workspaceContext, workspaceOf } from "./config.js";
|
|
6
6
|
import { lastDue, parseCron } from "./cron.js";
|
|
7
|
-
import { runAgent, stopAgents } from "./engine.js";
|
|
8
|
-
import { commitsAhead, enforceCommitPolicy, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
|
|
7
|
+
import { runAgent, runCommand, stopAgents } from "./engine.js";
|
|
8
|
+
import { commitsAhead, enforceCommitPolicy, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, removeWorktree, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
|
|
9
9
|
import { noTracker } from "./tracker.js";
|
|
10
10
|
import { recall, syncMemory, vaultPath } from "./memory.js";
|
|
11
11
|
import { claudeSettingsFile, readRules, rulesSection } from "./profile.js";
|
|
12
|
-
import { acquireWorkspace, clearCurrent, heldByOther, isPaused, liveRuns, readCurrent, readPending, releaseWorkspace, writeCurrent, writePending, writeRun, writeWorkspace, readCronState, writeCronState } from "./state.js";
|
|
12
|
+
import { acquireWorkspace, addUsage, clearCurrent, closeStale, heldByOther, isPaused, liveRuns, queueHandoffs, readCurrent, readHandoffs, readPending, readWindow, releaseWorkspace, takeHandoff, writeHandoffs, writeCurrent, writePending, writeRun, writeWorkspace, readCronState, writeCronState } from "./state.js";
|
|
13
|
+
import { WINDOW_MS, clock, refreshWindow, windowHold, windowStale } from "./usage.js";
|
|
13
14
|
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
|
|
14
15
|
class Park extends Error {
|
|
16
|
+
kind;
|
|
17
|
+
constructor(message, kind = 'parked') {
|
|
18
|
+
super(message);
|
|
19
|
+
this.kind = kind;
|
|
20
|
+
}
|
|
15
21
|
}
|
|
16
22
|
function withLabels(card, drop, add) {
|
|
17
23
|
return [...new Set([...card.labels.filter((l) => !drop(l)), ...add])];
|
|
@@ -21,7 +27,9 @@ const UNATTENDED = `# Running unattended
|
|
|
21
27
|
You are one step in a workflow. Nobody can answer questions and the
|
|
22
28
|
tracker connection is not available: the card and any plan are included above.
|
|
23
29
|
Do not write plan files; return your output as your final message. Make the
|
|
24
|
-
call the code supports and state it
|
|
30
|
+
call the code supports and state it. If the environment stops you (a missing
|
|
31
|
+
command, denied access, no network), name the command and the error in your
|
|
32
|
+
final message, and in a Plan or Dev step end with \`RESULT: BLOCKED\`.`;
|
|
25
33
|
// Agents keep the memory vault the way you do: durable learnings become notes.
|
|
26
34
|
const memoryNote = (vault) => `# Memory
|
|
27
35
|
|
|
@@ -34,6 +42,10 @@ rather than adding a second one. Skip it when nothing durable came up.`;
|
|
|
34
42
|
export function summarize(output) {
|
|
35
43
|
return output.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 3).join(' ').slice(0, 300);
|
|
36
44
|
}
|
|
45
|
+
// The last non-empty line of what a command printed, for run.json.
|
|
46
|
+
export function lastLine(output) {
|
|
47
|
+
return output.split('\n').map((l) => l.trim()).filter(Boolean).at(-1)?.slice(0, 300) ?? '';
|
|
48
|
+
}
|
|
37
49
|
// The run in progress, so a cancel signal can mark it.
|
|
38
50
|
let active = null;
|
|
39
51
|
const isVerdict = (d) => d.output === 'verdict';
|
|
@@ -55,6 +67,7 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
55
67
|
// Write back settings apply to card runs; a scheduled run has nothing to write to.
|
|
56
68
|
const writeBack = isCardTrigger(trigger) ? (workflow.writeBack ?? {}) : {};
|
|
57
69
|
const labelsOn = writeBack.labels !== false;
|
|
70
|
+
const cronRun = trigger.source === 'cron';
|
|
58
71
|
const startedAt = new Date().toISOString();
|
|
59
72
|
const workspace = workspaceOf(project);
|
|
60
73
|
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 };
|
|
@@ -73,11 +86,11 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
73
86
|
save();
|
|
74
87
|
return rec;
|
|
75
88
|
};
|
|
76
|
-
const end = (rec, result, output, logFile) => {
|
|
89
|
+
const end = (rec, result, output, logFile, command = false) => {
|
|
77
90
|
rec.endedAt = new Date().toISOString();
|
|
78
91
|
rec.result = result;
|
|
79
92
|
if (output !== undefined)
|
|
80
|
-
rec.summary = summarize(output);
|
|
93
|
+
rec.summary = command ? lastLine(output) : summarize(output);
|
|
81
94
|
if (logFile)
|
|
82
95
|
rec.log = basename(logFile);
|
|
83
96
|
save();
|
|
@@ -111,8 +124,20 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
111
124
|
await linear.comment(card, `${PLAN_HEADING}\n\n${planToPost}`);
|
|
112
125
|
planToPost = null;
|
|
113
126
|
};
|
|
114
|
-
async
|
|
115
|
-
|
|
127
|
+
const ping = async (kind, reason) => {
|
|
128
|
+
if (!rc.notify)
|
|
129
|
+
return;
|
|
130
|
+
try {
|
|
131
|
+
await rc.notify({ kind, card: card.identifier, title: card.title, workflow: workflowName, url: card.url || undefined, prUrl: record.prUrl, reason });
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
say(`discord: ${err.message}`);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
async function park(reason, kind = 'parked') {
|
|
138
|
+
say(`${kind === 'failed' ? 'failed' : 'parked'}: ${reason}`);
|
|
139
|
+
// The outcome is on disk before any tracker call, so a hung or failed write never loses it.
|
|
140
|
+
const outcome = done(kind, reason);
|
|
116
141
|
const comment = `**Parked:** ${reason}${lastFindings ? `\n\n${lastFindings}` : ''}\n\nFix the cause, then add \`${label}\` again to retry.`;
|
|
117
142
|
const add = labelsOn ? [LABELS.parked, ...(loops ? [LABELS.version(loops)] : [])] : [];
|
|
118
143
|
let commented = false;
|
|
@@ -123,14 +148,17 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
123
148
|
await endLabels(LABELS.parked, loops || undefined);
|
|
124
149
|
}
|
|
125
150
|
catch (err) {
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
151
|
+
if (isCardTrigger(trigger)) {
|
|
152
|
+
say(`could not reach ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} to park it (${err.message}). Will retry next minute.`);
|
|
153
|
+
// An empty comment is skipped on retry, so it is not posted twice.
|
|
154
|
+
writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [label], add, dropStatus: labelsOn, source: trigger.source }]);
|
|
155
|
+
}
|
|
131
156
|
}
|
|
132
|
-
|
|
157
|
+
await ping(kind, reason);
|
|
158
|
+
return outcome;
|
|
133
159
|
}
|
|
160
|
+
// Set once a worktree exists, so a scheduled run can remove it at the end.
|
|
161
|
+
let worktreeMade = '';
|
|
134
162
|
try {
|
|
135
163
|
if (!existsSync(join(project.path, '.git')))
|
|
136
164
|
throw new Park(`The project folder ${project.path} is not a git repository on this Mac.`);
|
|
@@ -138,6 +166,8 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
138
166
|
const from = opts.from ? steps.findIndex((d) => d.id === opts.from) : 0;
|
|
139
167
|
if (from < 0)
|
|
140
168
|
throw new Park(`Workflow ${workflowName} has no ${stepIdLabel(opts.from)} step.`);
|
|
169
|
+
// Command steps need no branch of their own: a run of only those works in the project folder.
|
|
170
|
+
const commandOnly = steps.slice(from).every((d) => d.command);
|
|
141
171
|
// A scheduled run has no card to plan on, so its task is the plan unless a Plan step writes one.
|
|
142
172
|
let plan = isCardTrigger(trigger) ? latestPlan(await linear.comments(card.identifier)) : card.description ?? null;
|
|
143
173
|
if (!plan && needsPlan(steps, from)) {
|
|
@@ -161,15 +191,53 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
161
191
|
say(pulled);
|
|
162
192
|
const context = workspaceContext(config, workspace);
|
|
163
193
|
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
|
-
const worktree = join(agentsDir(rc.configDir), 'worktrees', card.identifier);
|
|
165
|
-
|
|
166
|
-
|
|
194
|
+
const worktree = commandOnly ? project.path : join(agentsDir(rc.configDir), 'worktrees', card.identifier);
|
|
195
|
+
if (!commandOnly) {
|
|
196
|
+
ensureWorktree(project.path, worktree, card.branchName, base);
|
|
197
|
+
worktreeMade = worktree;
|
|
198
|
+
say(`working in ${worktree} on ${card.branchName}`);
|
|
199
|
+
}
|
|
167
200
|
const findings = new Map();
|
|
168
201
|
const runs = { plan: 0, dev: 0 };
|
|
169
202
|
let prUrl = '';
|
|
170
203
|
let logs = 0;
|
|
171
204
|
const planText = () => plan ?? '(No plan was posted on this card. Check the work against the card itself.)';
|
|
172
205
|
const logFile = (name) => join(runDir, `${String(logs++).padStart(2, '0')}-${name}.log`);
|
|
206
|
+
// Every agent step goes through here. A read step's stray edits are thrown
|
|
207
|
+
// away, so a later Dev step never commits them, and an agent process that
|
|
208
|
+
// fails ends the run as failed with its own error.
|
|
209
|
+
async function agent(def, rec, log, prompt) {
|
|
210
|
+
const r = def.command
|
|
211
|
+
? await runCommand({ command: def.command, cwd: worktree, logFile: log, timeoutMs: def.timeoutMs, env: { VSF_CARD: card.identifier, VSF_RUN_DIR: runDir, VSF_WORKFLOW: workflowName } })
|
|
212
|
+
: await runAgent({ engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs, platform: rc.platform, prompt });
|
|
213
|
+
// Saved with the step's end, failed or not.
|
|
214
|
+
if (r.usage) {
|
|
215
|
+
rec.usage = r.usage;
|
|
216
|
+
record.usage = addUsage(record.usage, r.usage);
|
|
217
|
+
}
|
|
218
|
+
if (!def.command && (!def.write || def.builtin === 'vs-qa'))
|
|
219
|
+
resetWorktree(worktree);
|
|
220
|
+
if (!r.ok) {
|
|
221
|
+
end(rec, 'failed', r.output, log, !!def.command);
|
|
222
|
+
throw new Park(`The ${def.label} step failed: ${r.error}. Log: ${basename(log)}`, 'failed');
|
|
223
|
+
}
|
|
224
|
+
return r;
|
|
225
|
+
}
|
|
226
|
+
// Cards a finished step names with HANDOFF lines go to the workflow it hands to.
|
|
227
|
+
async function handOff(def, rec, output) {
|
|
228
|
+
if (!def.handoff)
|
|
229
|
+
return;
|
|
230
|
+
if (!config.workflows[def.handoff]) {
|
|
231
|
+
say(`cannot hand cards to ${def.handoff}: no such workflow`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const cards = [...new Set([...output.matchAll(/^\s*HANDOFF:\s*([A-Za-z][A-Za-z0-9]*-\d+)\s*$/gm)].map((m) => m[1].toUpperCase()))];
|
|
235
|
+
queueHandoffs(rc.configDir, def.handoff, cards, card.identifier);
|
|
236
|
+
rec.handoffs = cards;
|
|
237
|
+
save();
|
|
238
|
+
if (cards.length)
|
|
239
|
+
say(`handed ${cards.join(', ')} to ${def.handoff}`);
|
|
240
|
+
}
|
|
173
241
|
async function check(def, round) {
|
|
174
242
|
const label = def.label;
|
|
175
243
|
const rec = begin(def);
|
|
@@ -203,14 +271,10 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
203
271
|
}
|
|
204
272
|
}
|
|
205
273
|
const log = logFile(def.id);
|
|
206
|
-
const r = await
|
|
207
|
-
engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
|
|
208
|
-
prompt: buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
209
|
-
});
|
|
210
|
-
if (def.builtin === 'vs-qa')
|
|
211
|
-
resetWorktree(worktree);
|
|
274
|
+
const r = await agent(def, rec, log, buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }));
|
|
212
275
|
const verdict = parseVerdict(r.output);
|
|
213
276
|
end(rec, verdict, r.output, log);
|
|
277
|
+
await handOff(def, rec, r.output);
|
|
214
278
|
return { step: label, verdict, report: r.output };
|
|
215
279
|
}
|
|
216
280
|
let i = from;
|
|
@@ -255,14 +319,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
255
319
|
await stage(stageOf(def), runs.plan + 1);
|
|
256
320
|
const rec = begin(def);
|
|
257
321
|
const log = logFile(def.builtin ? 'plan' : def.id);
|
|
258
|
-
const r = await
|
|
259
|
-
engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
|
|
260
|
-
prompt: buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none yet, you are writing it)', base, round: runs.plan + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
261
|
-
});
|
|
322
|
+
const r = await agent(def, rec, log, buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none yet, you are writing it)', base, round: runs.plan + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }));
|
|
262
323
|
const result = parseDevResult(r.output);
|
|
263
324
|
end(rec, result.status === 'blocked' ? 'blocked' : 'done', r.output, log);
|
|
264
325
|
if (result.status === 'blocked')
|
|
265
326
|
throw new Park(`The plan agent stopped: ${result.reason}`);
|
|
327
|
+
await handOff(def, rec, r.output);
|
|
266
328
|
plan = planBody(r.output);
|
|
267
329
|
planToPost = plan;
|
|
268
330
|
runs.plan++;
|
|
@@ -280,14 +342,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
280
342
|
const rec = begin(def);
|
|
281
343
|
const log = logFile(def.builtin ? 'dev' : def.id);
|
|
282
344
|
const before = headSha(worktree);
|
|
283
|
-
const dev = await
|
|
284
|
-
engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
|
|
285
|
-
prompt: buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round: runs.dev + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
286
|
-
});
|
|
345
|
+
const dev = await agent(def, rec, log, buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round: runs.dev + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }));
|
|
287
346
|
const result = parseDevResult(dev.output);
|
|
288
347
|
end(rec, result.status === 'blocked' ? 'blocked' : 'done', dev.output, log);
|
|
289
348
|
if (result.status === 'blocked')
|
|
290
349
|
throw new Park(`The dev agent stopped: ${result.reason}`);
|
|
350
|
+
await handOff(def, rec, dev.output);
|
|
291
351
|
if (commitsAhead(worktree, base) === 0)
|
|
292
352
|
throw new Park('The dev agent made no commits.');
|
|
293
353
|
// A round sent back with findings must change something, or QA would
|
|
@@ -309,13 +369,9 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
309
369
|
await stage(stageOf(def), loops || undefined);
|
|
310
370
|
const rec = begin(def);
|
|
311
371
|
const log = logFile(def.id);
|
|
312
|
-
const r = await
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
});
|
|
316
|
-
end(rec, r.ok ? 'done' : 'failed', r.output, log);
|
|
317
|
-
if (!r.ok)
|
|
318
|
-
throw new Park(`The ${def.label} step failed or timed out.`);
|
|
372
|
+
const r = await agent(def, rec, log, buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none)', base, round: loops || 1, extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }));
|
|
373
|
+
end(rec, 'done', r.output, log, !!def.command);
|
|
374
|
+
await handOff(def, rec, r.output);
|
|
319
375
|
}
|
|
320
376
|
findings.delete(i);
|
|
321
377
|
i++;
|
|
@@ -330,21 +386,27 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
330
386
|
if (writeBack.comment !== false) {
|
|
331
387
|
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.)` : ''}`);
|
|
332
388
|
}
|
|
333
|
-
say('verified');
|
|
334
|
-
|
|
389
|
+
say(cronRun ? 'done' : 'verified');
|
|
390
|
+
done(cronRun ? 'done' : 'verified');
|
|
335
391
|
}
|
|
336
392
|
catch (err) {
|
|
337
393
|
if (err instanceof Park)
|
|
338
|
-
return park(err.message);
|
|
339
|
-
return park(`Something went wrong: ${err.message}
|
|
394
|
+
return park(err.message, err.kind);
|
|
395
|
+
return park(`Something went wrong: ${err.message}`, 'failed');
|
|
340
396
|
}
|
|
341
397
|
finally {
|
|
342
398
|
active = null;
|
|
399
|
+
// A scheduled run that opened no pull request leaves no worktree or branch behind.
|
|
400
|
+
if (cronRun && worktreeMade && !record.prUrl)
|
|
401
|
+
removeWorktree(project.path, worktreeMade, card.branchName);
|
|
343
402
|
// Share whatever notes the agents wrote, whatever the outcome.
|
|
344
403
|
const shared = syncMemory(rc.selfCommand, rc.configDir);
|
|
345
404
|
if (shared)
|
|
346
405
|
say(shared);
|
|
347
406
|
}
|
|
407
|
+
// Outside the try, so a ping that throws can never park a verified run.
|
|
408
|
+
await ping(record.prUrl ? 'ready' : 'passed');
|
|
409
|
+
return cronRun ? 'done' : 'verified';
|
|
348
410
|
}
|
|
349
411
|
// The workspace whose queue a job waits in. A workflow whose project is
|
|
350
412
|
// missing still queues somewhere, so its run can say what is wrong.
|
|
@@ -364,6 +426,8 @@ export function cronCard(workflow, task, at) {
|
|
|
364
426
|
labels: [],
|
|
365
427
|
};
|
|
366
428
|
}
|
|
429
|
+
// How long a handed card may wait for its run to start.
|
|
430
|
+
const HANDOFF_MAX_MS = 24 * 60 * 60_000;
|
|
367
431
|
const cronKey = (workflow, schedule) => `${workflow} ${schedule}`;
|
|
368
432
|
// Jobs waiting on the workflows' triggers, in workflow order, at most one per
|
|
369
433
|
// workspace and none in a busy one: a card carrying a trigger label, or a
|
|
@@ -400,7 +464,64 @@ export async function findJobs(rc, q = {}) {
|
|
|
400
464
|
out.push({ ...job, workspace });
|
|
401
465
|
return out.length >= limit;
|
|
402
466
|
};
|
|
467
|
+
// Cards other workflows handed over, in the order they were queued. They
|
|
468
|
+
// need no label. Stale or unknown ones are dropped; one whose tracker cannot
|
|
469
|
+
// be read stays queued. True once the limit is reached.
|
|
470
|
+
const takeHandoffs = async () => {
|
|
471
|
+
const queue = readHandoffs(rc.configDir);
|
|
472
|
+
const keep = [];
|
|
473
|
+
let full = false;
|
|
474
|
+
for (const h of queue) {
|
|
475
|
+
if (q.workflow && h.workflow !== q.workflow) {
|
|
476
|
+
keep.push(h);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (now.getTime() - new Date(h.at).getTime() > HANDOFF_MAX_MS) {
|
|
480
|
+
rc.log(`Dropped the handoff of ${h.card} to ${h.workflow}: it waited more than 24 hours.`);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const workflow = rc.config.workflows[h.workflow];
|
|
484
|
+
if (!workflow) {
|
|
485
|
+
rc.log(`Dropped the handoff of ${h.card} to ${h.workflow}: no such workflow.`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const team = h.card.split('-')[0].toUpperCase();
|
|
489
|
+
const triggers = workflow.triggers.filter(isCardTrigger).filter((t) => t.team === team);
|
|
490
|
+
if (!triggers.length) {
|
|
491
|
+
rc.log(`Dropped the handoff of ${h.card} to ${h.workflow}: it has no trigger for team ${team}.`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
keep.push(h);
|
|
495
|
+
if (full)
|
|
496
|
+
continue;
|
|
497
|
+
let found = null;
|
|
498
|
+
let failure = '';
|
|
499
|
+
for (const trigger of triggers) {
|
|
500
|
+
try {
|
|
501
|
+
found = { card: await rc.trackers(trigger.source).card(h.card), trigger };
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
failure = err.message;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (!found) {
|
|
509
|
+
rc.log(`Could not read ${h.card} for its handoff (${failure}). Trying again next minute.`);
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const workspace = jobWorkspace(rc.config, workflow, found.card);
|
|
513
|
+
if (taken.has(workspace) || q.skip?.has(h.card) || out.some((j) => j.card.identifier === h.card))
|
|
514
|
+
continue;
|
|
515
|
+
if (take({ card: found.card, trigger: found.trigger, workflow: h.workflow }, workspace))
|
|
516
|
+
full = true;
|
|
517
|
+
}
|
|
518
|
+
if (keep.length !== queue.length)
|
|
519
|
+
writeHandoffs(rc.configDir, keep);
|
|
520
|
+
return full;
|
|
521
|
+
};
|
|
403
522
|
try {
|
|
523
|
+
if (!q.only && !q.schedule && (await takeHandoffs()))
|
|
524
|
+
return out;
|
|
404
525
|
for (const [name, workflow] of workflows) {
|
|
405
526
|
for (const trigger of workflow.triggers) {
|
|
406
527
|
if (!isCardTrigger(trigger)) {
|
|
@@ -523,6 +644,22 @@ function queueCancel(configDir, cardId, comment, source = 'linear') {
|
|
|
523
644
|
// nothing more to do
|
|
524
645
|
}
|
|
525
646
|
}
|
|
647
|
+
// The card runs that take a slot under the cap. A lock with no card is a
|
|
648
|
+
// check, not a run.
|
|
649
|
+
const cardRuns = (configDir) => liveRuns(configDir).filter((c) => c.card && c.pid !== process.pid);
|
|
650
|
+
// Why no new run may start now: the 5 hour window is held, or the cap is
|
|
651
|
+
// reached. Null when one may.
|
|
652
|
+
export function gateReason(rc, now = new Date()) {
|
|
653
|
+
const w = readWindow(rc.configDir);
|
|
654
|
+
const until = windowHold(w, rc.config.pauseAt ?? DEFAULTS.pauseAt, now);
|
|
655
|
+
if (until)
|
|
656
|
+
return `The 5 hour window is at ${Math.round(w.utilization)}%, so no new run starts until ${clock(until)}. Running cards finish.`;
|
|
657
|
+
const max = rc.config.maxRuns ?? 0;
|
|
658
|
+
const running = max > 0 ? cardRuns(rc.configDir).length : 0;
|
|
659
|
+
if (max > 0 && running >= max)
|
|
660
|
+
return `${running} of ${max} runs are going, so no new one starts until one finishes.`;
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
526
663
|
// Runs one job in its workspace's queue: a pinned card, a workflow's schedule,
|
|
527
664
|
// or the first job waiting in a free workspace. The workspace lock keeps two
|
|
528
665
|
// runs from working in one workspace at once. `child` is a run the dispatcher
|
|
@@ -570,6 +707,12 @@ export async function tick(rc, only, opts = {}) {
|
|
|
570
707
|
await flushPending(rc);
|
|
571
708
|
if (!only && isPaused(rc.configDir))
|
|
572
709
|
return 0;
|
|
710
|
+
// A named card goes ahead of the cap and the hold, as it does a pause.
|
|
711
|
+
const gate = !only && !opts.child ? gateReason(rc) : null;
|
|
712
|
+
if (gate) {
|
|
713
|
+
rc.log(gate);
|
|
714
|
+
return 0;
|
|
715
|
+
}
|
|
573
716
|
let job;
|
|
574
717
|
try {
|
|
575
718
|
const busy = only ? undefined : new Set(liveRuns(rc.configDir).filter((c) => c.pid !== process.pid).map((c) => workspaceOf(c)));
|
|
@@ -600,8 +743,11 @@ export async function tick(rc, only, opts = {}) {
|
|
|
600
743
|
return 1;
|
|
601
744
|
}
|
|
602
745
|
held = job.workspace;
|
|
746
|
+
// A handed card leaves the queue only once its run has started.
|
|
747
|
+
if (isCardTrigger(job.trigger))
|
|
748
|
+
takeHandoff(rc.configDir, job.card.identifier, job.workflow);
|
|
603
749
|
const outcome = await runCard(job.card, job.trigger, job.workflow, workflow, resolved.project, rc, { from: opts.from });
|
|
604
|
-
return outcome === 'verified' ? 0 : 1;
|
|
750
|
+
return outcome === 'verified' || outcome === 'done' ? 0 : 1;
|
|
605
751
|
}
|
|
606
752
|
catch (err) {
|
|
607
753
|
rc.log(`Error: ${err.message}`);
|
|
@@ -622,6 +768,7 @@ const realDeps = (rc) => ({
|
|
|
622
768
|
return spawn(bin, [...rest, 'agents', 'tick', ...args], { stdio: 'inherit' });
|
|
623
769
|
},
|
|
624
770
|
waitMs: 60_000,
|
|
771
|
+
window: () => refreshWindow(rc.configDir),
|
|
625
772
|
});
|
|
626
773
|
// The background runner. The service starts a new pass only once the last one
|
|
627
774
|
// ended, so this pass stays up while cards run: it starts one child process
|
|
@@ -655,15 +802,66 @@ export async function dispatch(rc, over = {}) {
|
|
|
655
802
|
};
|
|
656
803
|
process.on('SIGTERM', onSignal);
|
|
657
804
|
process.on('SIGINT', onSignal);
|
|
805
|
+
// The window is read once at the start when the last reading is old, then
|
|
806
|
+
// every 5 minutes while runs go on, one read at a time.
|
|
807
|
+
let reading = false;
|
|
808
|
+
const readWindowNow = async () => {
|
|
809
|
+
if (reading)
|
|
810
|
+
return;
|
|
811
|
+
reading = true;
|
|
812
|
+
try {
|
|
813
|
+
await deps.window();
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
// window.json keeps the last reading
|
|
817
|
+
}
|
|
818
|
+
finally {
|
|
819
|
+
reading = false;
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
let timer;
|
|
823
|
+
let wasHeld = false;
|
|
658
824
|
try {
|
|
825
|
+
if (windowStale(readWindow(rc.configDir)))
|
|
826
|
+
await readWindowNow();
|
|
827
|
+
timer = setInterval(() => void readWindowNow(), WINDOW_MS);
|
|
828
|
+
timer.unref();
|
|
659
829
|
for (;;) {
|
|
830
|
+
// Runs whose process ended without an outcome are closed as failed.
|
|
831
|
+
try {
|
|
832
|
+
for (const card of closeStale(rc.configDir, liveRuns(rc.configDir)))
|
|
833
|
+
rc.log(`${card} failed: the run stopped without finishing.`);
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
// looked at again next pass
|
|
837
|
+
}
|
|
660
838
|
if (deps.reload)
|
|
661
839
|
rc = { ...rc, config: deps.reload() };
|
|
662
840
|
await flushPending(rc);
|
|
663
|
-
|
|
841
|
+
const paused = isPaused(rc.configDir);
|
|
842
|
+
const w = readWindow(rc.configDir);
|
|
843
|
+
const heldUntil = windowHold(w, rc.config.pauseAt ?? DEFAULTS.pauseAt, new Date());
|
|
844
|
+
if (heldUntil && !wasHeld)
|
|
845
|
+
rc.log(`The 5 hour window is at ${Math.round(w.utilization)}%, so no new run starts until ${clock(heldUntil)}. Running cards finish.`);
|
|
846
|
+
if (!heldUntil && wasHeld)
|
|
847
|
+
rc.log('The 5 hour window reset, so new runs start again.');
|
|
848
|
+
wasHeld = !!heldUntil;
|
|
849
|
+
if (rc.summary) {
|
|
850
|
+
try {
|
|
851
|
+
await rc.summary(paused);
|
|
852
|
+
}
|
|
853
|
+
catch (err) {
|
|
854
|
+
rc.log(`discord: ${err.message}`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// Only card runs take a slot under the cap.
|
|
858
|
+
const running = new Set([...children.keys(), ...cardRuns(rc.configDir).map((c) => workspaceOf(c))]);
|
|
859
|
+
const max = rc.config.maxRuns ?? 0;
|
|
860
|
+
const limit = max > 0 ? max - running.size : Infinity;
|
|
861
|
+
if (!paused && !heldUntil && limit > 0) {
|
|
664
862
|
const busy = new Set([...children.keys(), ...liveRuns(rc.configDir).map((c) => workspaceOf(c))]);
|
|
665
863
|
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 })) {
|
|
864
|
+
for (const job of await findJobs(rc, { busy, skip, limit })) {
|
|
667
865
|
started.set(job.card.identifier, Date.now());
|
|
668
866
|
rc.log(`${job.card.identifier} starts ${job.workflow} in workspace ${job.workspace}.`);
|
|
669
867
|
const child = deps.start(jobArgs(job));
|
|
@@ -698,6 +896,7 @@ export async function dispatch(rc, over = {}) {
|
|
|
698
896
|
return 1;
|
|
699
897
|
}
|
|
700
898
|
finally {
|
|
899
|
+
clearInterval(timer);
|
|
701
900
|
process.off('SIGTERM', onSignal);
|
|
702
901
|
process.off('SIGINT', onSignal);
|
|
703
902
|
clearCurrent(rc.configDir);
|
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/service.js
CHANGED
|
@@ -13,6 +13,6 @@ const linux = {
|
|
|
13
13
|
start: (home, program, logPath) => systemd.startRunner(home, program, launchd.runnerEnv(), logPath),
|
|
14
14
|
stop: systemd.stopRunner,
|
|
15
15
|
programArgs: systemd.programArgs,
|
|
16
|
-
checks: () => [systemd.lingerCheck()],
|
|
16
|
+
checks: () => [systemd.lingerCheck(), systemd.sudoCheck()],
|
|
17
17
|
};
|
|
18
18
|
export const runnerService = (platform = process.platform) => (platform === 'darwin' ? mac : linux);
|
package/dist/state.d.ts
CHANGED
|
@@ -29,6 +29,14 @@ export declare const liveRuns: (configDir: string) => Current[];
|
|
|
29
29
|
export declare const clearLock: (configDir: string, c: Current) => void;
|
|
30
30
|
export declare const isPaused: (configDir: string) => boolean;
|
|
31
31
|
export declare function setPaused(configDir: string, paused: boolean): void;
|
|
32
|
+
export interface Window {
|
|
33
|
+
checkedAt: string;
|
|
34
|
+
utilization?: number;
|
|
35
|
+
resetsAt?: string;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function readWindow(configDir: string): Window | null;
|
|
39
|
+
export declare function writeWindow(configDir: string, w: Window): void;
|
|
32
40
|
export interface Pending {
|
|
33
41
|
card: string;
|
|
34
42
|
comment: string;
|
|
@@ -39,7 +47,19 @@ export interface Pending {
|
|
|
39
47
|
}
|
|
40
48
|
export declare function readPending(configDir: string): Pending[];
|
|
41
49
|
export declare function writePending(configDir: string, pending: Pending[]): void;
|
|
42
|
-
export type Outcome = 'running' | 'verified' | 'changes' | 'parked' | 'cancelled';
|
|
50
|
+
export type Outcome = 'running' | 'verified' | 'done' | 'changes' | 'parked' | 'failed' | 'cancelled';
|
|
51
|
+
export interface Usage {
|
|
52
|
+
input?: number;
|
|
53
|
+
output?: number;
|
|
54
|
+
cached?: number;
|
|
55
|
+
costUsd?: number;
|
|
56
|
+
model?: string;
|
|
57
|
+
}
|
|
58
|
+
export type RunUsage = Omit<Usage, 'model'> & {
|
|
59
|
+
models?: string[];
|
|
60
|
+
};
|
|
61
|
+
export declare function addUsage(total: RunUsage | undefined, step: Usage): RunUsage;
|
|
62
|
+
export declare function usageText(u: Usage | RunUsage | undefined): string;
|
|
43
63
|
export interface StepRecord {
|
|
44
64
|
step: string;
|
|
45
65
|
label: string;
|
|
@@ -48,6 +68,8 @@ export interface StepRecord {
|
|
|
48
68
|
result?: string;
|
|
49
69
|
summary?: string;
|
|
50
70
|
log?: string;
|
|
71
|
+
usage?: Usage;
|
|
72
|
+
handoffs?: string[];
|
|
51
73
|
}
|
|
52
74
|
export interface RunRecord {
|
|
53
75
|
card: string;
|
|
@@ -63,6 +85,7 @@ export interface RunRecord {
|
|
|
63
85
|
workspace?: string;
|
|
64
86
|
startedAt: string;
|
|
65
87
|
endedAt?: string;
|
|
88
|
+
usage?: RunUsage;
|
|
66
89
|
}
|
|
67
90
|
export declare const runsDir: (configDir: string) => string;
|
|
68
91
|
export declare function writeRun(runDir: string, run: RunRecord): void;
|
|
@@ -76,3 +99,19 @@ export interface RunEntry {
|
|
|
76
99
|
export declare function listRuns(configDir: string, card?: string): RunEntry[];
|
|
77
100
|
export declare function readCronState(configDir: string): Record<string, string>;
|
|
78
101
|
export declare function writeCronState(configDir: string, state: Record<string, string>): void;
|
|
102
|
+
export interface Handoff {
|
|
103
|
+
card: string;
|
|
104
|
+
workflow: string;
|
|
105
|
+
from: string;
|
|
106
|
+
at: string;
|
|
107
|
+
}
|
|
108
|
+
export declare function readHandoffs(configDir: string): Handoff[];
|
|
109
|
+
export declare function writeHandoffs(configDir: string, handoffs: Handoff[]): void;
|
|
110
|
+
export declare function queueHandoffs(configDir: string, workflow: string, cards: string[], from: string, now?: Date): Handoff[];
|
|
111
|
+
export declare function takeHandoff(configDir: string, card: string, workflow: string): void;
|
|
112
|
+
export declare function lastSeen(run: RunRecord): string;
|
|
113
|
+
export declare const STOPPED_REASON = "The run stopped without finishing: its process ended before it could record an outcome.";
|
|
114
|
+
export declare function closeStale(configDir: string, live: {
|
|
115
|
+
runDir?: string;
|
|
116
|
+
card?: string;
|
|
117
|
+
}[]): string[];
|