@voidlabs.gg/voidshift-agents 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/dist/config.d.ts +19 -0
- package/dist/config.js +43 -0
- package/dist/control.d.ts +14 -2
- package/dist/control.js +91 -48
- package/dist/index.js +132 -10
- package/dist/manage.d.ts +2 -1
- package/dist/manage.js +46 -1
- package/dist/runner.d.ts +25 -2
- package/dist/runner.js +192 -56
- package/dist/state.d.ts +11 -1
- package/dist/state.js +57 -3
- package/dist/workflow.d.ts +4 -0
- package/dist/workflow.js +15 -0
- package/pack/vs-plan/SKILL.md +4 -1
- package/pack/vs-plan-review/SKILL.md +3 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -74,9 +74,9 @@ or `vs-parked`) and the round (`vs-v1`, `vs-v2`). On Linear it posts the plan an
|
|
|
74
74
|
one final comment, nothing in between.
|
|
75
75
|
|
|
76
76
|
```sh
|
|
77
|
-
vsf agents status # runner, the card
|
|
77
|
+
vsf agents status # runner, the card each workspace is on, parked cards
|
|
78
78
|
vsf agents run ENG-123 [--from qa] # run one card now, in this terminal
|
|
79
|
-
vsf agents start | stop | restart # the background runner (stop cancels
|
|
79
|
+
vsf agents start | stop | restart # the background runner (stop cancels its cards)
|
|
80
80
|
vsf agents pause | resume # pick up no new cards for a while
|
|
81
81
|
vsf agents cancel | retry ENG-123 # stop a card, or queue a parked one again
|
|
82
82
|
vsf agents runs | show ENG-123 # what happened, step by step
|
|
@@ -84,11 +84,25 @@ vsf agents logs [ENG-123] [-f] # the runner log, or a card's step logs
|
|
|
84
84
|
vsf agents workflows # workflows in the order they match
|
|
85
85
|
vsf agents workflow add|edit|show|rename|move|rm
|
|
86
86
|
vsf agents projects | project add|edit|show|rename|rm
|
|
87
|
+
vsf agents workspace add|list|rm # folders of repos, each with its own queue
|
|
87
88
|
vsf agents homes | link DIR # where the agents are installed
|
|
88
89
|
vsf agents clean # old worktrees and run logs
|
|
89
90
|
vsf agents validate [DIR] # check the config and agent folders
|
|
90
91
|
```
|
|
91
92
|
|
|
93
|
+
Each workspace has one queue: cards in different workspaces run side by side,
|
|
94
|
+
two cards in one workspace one after the other. Projects are in the `default`
|
|
95
|
+
workspace until you move them:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
vsf agents workspace add solvia --root ~/work/solvia # steps carry ~/work/solvia/CONTEXT.md
|
|
99
|
+
vsf agents project edit erp --workspace solvia --match DS06
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`--match` names a tracker project (or its first word, so `DS06` matches "DS06
|
|
103
|
+
Atleurope ERP"). A card of that project runs in the matching project, in any
|
|
104
|
+
workspace, before the workflow's own project is considered.
|
|
105
|
+
|
|
92
106
|
A workflow can also be a folder you write by hand,
|
|
93
107
|
`~/.config/voidshift/agents/defs/NAME/agent.json`, with its prompt files next to
|
|
94
108
|
it. Steps are the built-in ids above or your own:
|
package/dist/config.d.ts
CHANGED
|
@@ -52,7 +52,16 @@ export declare function describeTrigger(t: Trigger): string;
|
|
|
52
52
|
export interface Project {
|
|
53
53
|
path: string;
|
|
54
54
|
base?: string;
|
|
55
|
+
workspace?: string;
|
|
56
|
+
match?: string[];
|
|
55
57
|
}
|
|
58
|
+
export interface Workspace {
|
|
59
|
+
root: string;
|
|
60
|
+
context?: string;
|
|
61
|
+
}
|
|
62
|
+
export declare const DEFAULT_WORKSPACE = "default";
|
|
63
|
+
export declare const WORKSPACE_NAME: RegExp;
|
|
64
|
+
export declare const workspaceOf: (project: Pick<Project, "workspace"> | undefined) => string;
|
|
56
65
|
export interface WriteBack {
|
|
57
66
|
labels?: boolean;
|
|
58
67
|
state?: string | null;
|
|
@@ -72,6 +81,7 @@ export interface AgentsConfig {
|
|
|
72
81
|
review?: string;
|
|
73
82
|
};
|
|
74
83
|
projects: Record<string, Project>;
|
|
84
|
+
workspaces: Record<string, Workspace>;
|
|
75
85
|
workflows: Record<string, Workflow>;
|
|
76
86
|
reviewState: string;
|
|
77
87
|
timeoutMinutes: number;
|
|
@@ -104,6 +114,15 @@ export declare function agentFolders(configDir: string): string[];
|
|
|
104
114
|
export declare function readAgentFolder(dir: string): Workflow;
|
|
105
115
|
export declare function readAgentsConfig(configDir: string): AgentsConfig;
|
|
106
116
|
export declare function writeAgentsConfig(configDir: string, config: AgentsConfig): void;
|
|
117
|
+
export declare const workspacesInUse: (config: Pick<AgentsConfig, "projects" | "workspaces">) => string[];
|
|
118
|
+
export declare function matchesProject(entry: string, projectName: string): boolean;
|
|
119
|
+
export declare function resolveProject(config: Pick<AgentsConfig, 'projects'>, workflow: Pick<Workflow, 'project'>, card: {
|
|
120
|
+
projectName?: string;
|
|
121
|
+
}): {
|
|
122
|
+
name: string;
|
|
123
|
+
project: Project;
|
|
124
|
+
} | null;
|
|
125
|
+
export declare function workspaceContext(config: Pick<AgentsConfig, 'workspaces'>, name: string): string;
|
|
107
126
|
export interface ResolvedStep {
|
|
108
127
|
id: string;
|
|
109
128
|
label: string;
|
package/dist/config.js
CHANGED
|
@@ -43,6 +43,9 @@ export function describeTrigger(t) {
|
|
|
43
43
|
return `cron ${t.schedule}`;
|
|
44
44
|
return `${t.source === 'voidtrack' ? 'VoidTrack ' : ''}${t.team} + ${t.label}`;
|
|
45
45
|
}
|
|
46
|
+
export const DEFAULT_WORKSPACE = 'default';
|
|
47
|
+
export const WORKSPACE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
48
|
+
export const workspaceOf = (project) => project?.workspace || DEFAULT_WORKSPACE;
|
|
46
49
|
// Step lists to start a workflow from.
|
|
47
50
|
export const TEMPLATES = {
|
|
48
51
|
build: { steps: ['vs-dev', 'vs-qa'], maxRounds: 3 },
|
|
@@ -53,6 +56,7 @@ export const DEFAULTS = {
|
|
|
53
56
|
engine: 'claude',
|
|
54
57
|
models: { dev: 'opus', review: 'sonnet' },
|
|
55
58
|
projects: {},
|
|
59
|
+
workspaces: {},
|
|
56
60
|
workflows: {},
|
|
57
61
|
reviewState: 'In Review',
|
|
58
62
|
timeoutMinutes: 45,
|
|
@@ -157,6 +161,45 @@ export function writeAgentsConfig(configDir, config) {
|
|
|
157
161
|
const workflows = Object.fromEntries(Object.entries(config.workflows).filter(([name]) => !folders?.[name]));
|
|
158
162
|
writeFileSync(configPath(configDir), JSON.stringify({ ...rest, workflows }, null, 2) + '\n');
|
|
159
163
|
}
|
|
164
|
+
// Every workspace a config uses: the named ones, then any a project names,
|
|
165
|
+
// the default one included.
|
|
166
|
+
export const workspacesInUse = (config) => [
|
|
167
|
+
...new Set([...Object.keys(config.workspaces ?? {}), ...Object.values(config.projects).map(workspaceOf)]),
|
|
168
|
+
];
|
|
169
|
+
// Whether a match entry names a tracker project: the whole name, or its first
|
|
170
|
+
// word, so "DS06" matches "DS06 Atleurope ERP". Case does not matter.
|
|
171
|
+
export function matchesProject(entry, projectName) {
|
|
172
|
+
const want = entry.trim().toLowerCase();
|
|
173
|
+
const name = projectName.trim().toLowerCase();
|
|
174
|
+
return !!want && (want === name || want === name.split(/\s+/)[0]);
|
|
175
|
+
}
|
|
176
|
+
// The project a card runs in. Explicit matches on the card's tracker project
|
|
177
|
+
// come first, in every workspace, and only then the workflow's own project.
|
|
178
|
+
// A scheduled run has no tracker project, so it always takes the workflow's.
|
|
179
|
+
export function resolveProject(config, workflow, card) {
|
|
180
|
+
if (card.projectName) {
|
|
181
|
+
for (const [name, project] of Object.entries(config.projects)) {
|
|
182
|
+
if (project.match?.some((m) => matchesProject(m, card.projectName)))
|
|
183
|
+
return { name, project };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const project = config.projects[workflow.project];
|
|
187
|
+
return project ? { name: workflow.project, project } : null;
|
|
188
|
+
}
|
|
189
|
+
// The workspace's context text for the step prompts: its context file, or
|
|
190
|
+
// CONTEXT.md in its root. Empty when there is none.
|
|
191
|
+
export function workspaceContext(config, name) {
|
|
192
|
+
const ws = config.workspaces?.[name];
|
|
193
|
+
if (!ws)
|
|
194
|
+
return '';
|
|
195
|
+
const file = ws.context ? resolve(ws.root, ws.context) : join(ws.root, 'CONTEXT.md');
|
|
196
|
+
try {
|
|
197
|
+
return readFileSync(file, 'utf8').trim();
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
160
203
|
const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
|
|
161
204
|
const packSkill = (name) => join(packDir, name, 'SKILL.md');
|
|
162
205
|
// The file a custom step's prompt comes from: its prompt file, a pack skill,
|
package/dist/control.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type AgentsConfig, type Tracker as Kind } from './config.ts';
|
|
2
2
|
import { type Current, type RunEntry } from './state.ts';
|
|
3
|
+
import { type Trackers } from './tracker.ts';
|
|
3
4
|
import type { Check, ToolCommand, ToolContext } from './types.ts';
|
|
4
5
|
export { parsePlistArgs } from './launchd.ts';
|
|
5
6
|
export declare const trackersInUse: (config: Pick<AgentsConfig, "workflows">) => Kind[];
|
|
@@ -9,8 +10,8 @@ export declare function stampDate(stamp: string): Date;
|
|
|
9
10
|
export declare function countRounds(steps: {
|
|
10
11
|
result?: string;
|
|
11
12
|
}[]): number;
|
|
12
|
-
export declare const isInterrupted: (e: RunEntry, live: Current
|
|
13
|
-
export declare function formatRunLine(e: RunEntry, now?: number, live?: Current
|
|
13
|
+
export declare const isInterrupted: (e: RunEntry, live: Current[]) => boolean;
|
|
14
|
+
export declare function formatRunLine(e: RunEntry, now?: number, live?: Current[]): string;
|
|
14
15
|
export declare function scanRunnerErrors(text: string, now: number): {
|
|
15
16
|
count: number;
|
|
16
17
|
last: string;
|
|
@@ -23,5 +24,16 @@ export interface CancelDeps {
|
|
|
23
24
|
cancelCard: (card: string, label: string, source: Kind) => Promise<boolean>;
|
|
24
25
|
}
|
|
25
26
|
export declare function cancelCurrent(ctx: ToolContext, c: Current, deps: CancelDeps): Promise<void>;
|
|
27
|
+
export declare function trackerWarnings(kinds: Kind[], trackers: Trackers): Promise<{
|
|
28
|
+
kind: Kind;
|
|
29
|
+
message: string;
|
|
30
|
+
}[]>;
|
|
31
|
+
export declare function workingLine(current: (Current & {
|
|
32
|
+
elapsedMinutes: number;
|
|
33
|
+
}) | null): string;
|
|
34
|
+
export declare function workspaceLines(workspaces: string[], running: (Current & {
|
|
35
|
+
elapsedMinutes: number;
|
|
36
|
+
})[]): string[];
|
|
37
|
+
export declare function pickCancel(locks: Current[], dispatcher: Current | null, card?: string): Current | string;
|
|
26
38
|
export declare const controlCommands: Record<string, ToolCommand>;
|
|
27
39
|
export declare function controlChecks(ctx: ToolContext): Promise<Check[]>;
|
package/dist/control.js
CHANGED
|
@@ -2,13 +2,13 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { agentsDir, isCardTrigger, readAgentsConfig } from "./config.js";
|
|
6
|
-
import { isPaused, listRuns, ours,
|
|
5
|
+
import { agentsDir, isCardTrigger, readAgentsConfig, workspaceOf, workspacesInUse } from "./config.js";
|
|
6
|
+
import { isPaused, listRuns, ours, clearLock, liveCurrent, liveRuns, readCurrent, setPaused, workspaceLocks, writeRun } from "./state.js";
|
|
7
7
|
import { runnerService } from "./service.js";
|
|
8
8
|
import { TRACKERS, trackersFor } from "./tracker.js";
|
|
9
9
|
import { parseFrom, resolveName, stepNames } from "./names.js";
|
|
10
10
|
import { isStatusLabel } from "./workflow.js";
|
|
11
|
-
import { cancelCard, tick } from "./runner.js";
|
|
11
|
+
import { cancelCard, dispatch, tick } from "./runner.js";
|
|
12
12
|
const HOUR = 3_600_000;
|
|
13
13
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
14
14
|
const CARD_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
@@ -78,9 +78,9 @@ export function countRounds(steps) {
|
|
|
78
78
|
}
|
|
79
79
|
return rounds;
|
|
80
80
|
}
|
|
81
|
-
// A run that says 'running' but is
|
|
82
|
-
export const isInterrupted = (e, live) => e.record?.outcome === 'running' && !(
|
|
83
|
-
export function formatRunLine(e, now = Date.now(), live =
|
|
81
|
+
// A run that says 'running' but is none of the live runs was cut off.
|
|
82
|
+
export const isInterrupted = (e, live) => e.record?.outcome === 'running' && !live.some((l) => (l.runDir ? l.runDir === e.dir : l.card === e.card));
|
|
83
|
+
export function formatRunLine(e, now = Date.now(), live = []) {
|
|
84
84
|
const r = e.record;
|
|
85
85
|
if (!r)
|
|
86
86
|
return `${e.card} ${when(stampDate(e.stamp))} older run, logs only (${plural(e.logs.length, 'log')})`;
|
|
@@ -121,14 +121,14 @@ const readLog = (configDir) => {
|
|
|
121
121
|
}
|
|
122
122
|
};
|
|
123
123
|
const USAGE = {
|
|
124
|
-
status: 'status [--json] Runner,
|
|
124
|
+
status: 'status [--json] Runner, the card each workspace is on, parked cards and workflows',
|
|
125
125
|
start: 'start Start the background runner',
|
|
126
|
-
stop: 'stop Stop the runner and cancel the
|
|
126
|
+
stop: 'stop Stop the runner and cancel the cards it is working on',
|
|
127
127
|
restart: 'restart Stop and start the runner',
|
|
128
128
|
pause: 'pause Pick up no new cards; a card in progress carries on',
|
|
129
129
|
resume: 'resume Pick up new cards again',
|
|
130
130
|
run: 'run [CARD] [--workflow W] [--from STEP] Run now instead of waiting for the next minute',
|
|
131
|
-
cancel: 'cancel [CARD] Stop
|
|
131
|
+
cancel: 'cancel [CARD] Stop a card being worked on and clear its labels',
|
|
132
132
|
retry: 'retry CARD [--workflow W] Queue a parked or cancelled card again',
|
|
133
133
|
runs: 'runs [CARD] [--limit N] [--json] Past runs, newest first',
|
|
134
134
|
show: 'show CARD [--json] Steps, outcome and labels of the latest run',
|
|
@@ -215,7 +215,7 @@ export async function cancelCurrent(ctx, c, deps) {
|
|
|
215
215
|
}
|
|
216
216
|
else
|
|
217
217
|
ctx.log('Cleared a stale lock.');
|
|
218
|
-
|
|
218
|
+
clearLock(ctx.configDir, c);
|
|
219
219
|
}
|
|
220
220
|
const realDeps = (ctx) => ({
|
|
221
221
|
ours,
|
|
@@ -230,23 +230,30 @@ const realDeps = (ctx) => ({
|
|
|
230
230
|
}
|
|
231
231
|
},
|
|
232
232
|
});
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const trackers = trackersFor(ctx.configDir);
|
|
239
|
-
for (const kind of trackersInUse(readAgentsConfig(ctx.configDir))) {
|
|
233
|
+
// One message per tracker whose key is missing or refused. The tick skips a
|
|
234
|
+
// tracker it cannot open, so these warn rather than stop the runner.
|
|
235
|
+
export async function trackerWarnings(kinds, trackers) {
|
|
236
|
+
const out = [];
|
|
237
|
+
for (const kind of kinds) {
|
|
240
238
|
try {
|
|
241
239
|
await trackers(kind).viewer();
|
|
242
240
|
}
|
|
243
241
|
catch (err) {
|
|
244
242
|
const msg = err.message;
|
|
245
243
|
const flag = kind === 'linear' ? '' : ` --${kind}`;
|
|
246
|
-
|
|
247
|
-
return 1;
|
|
244
|
+
out.push({ kind, message: /^No .* API key/.test(msg) ? msg : `${TRACKERS[kind].name} did not accept the key: ${msg}. Run vsf agents key${flag}.` });
|
|
248
245
|
}
|
|
249
246
|
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
async function startCmd(ctx) {
|
|
250
|
+
if (!Object.keys(readAgentsConfig(ctx.configDir).workflows).length) {
|
|
251
|
+
ctx.log('No workflows yet. Create one first: vsf agents workflow add.');
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
for (const w of await trackerWarnings(trackersInUse(readAgentsConfig(ctx.configDir)), trackersFor(ctx.configDir))) {
|
|
255
|
+
ctx.log(`Warning: ${w.message.replace(/\.?$/, '.')} Workflows on ${TRACKERS[w.kind].name} are skipped until then.`);
|
|
256
|
+
}
|
|
250
257
|
setPaused(ctx.configDir, false);
|
|
251
258
|
if (!runnerService().start(ctx.home, program(ctx), logPath(ctx.configDir))) {
|
|
252
259
|
ctx.log('Could not start the runner. Check vsf agents logs.');
|
|
@@ -256,13 +263,29 @@ async function startCmd(ctx) {
|
|
|
256
263
|
}
|
|
257
264
|
async function stopCmd(ctx) {
|
|
258
265
|
runnerService().stop(ctx.home);
|
|
259
|
-
// Stopping the runner may have stopped
|
|
266
|
+
// Stopping the runner may have stopped its runs; finish or tidy up whatever the locks still name.
|
|
267
|
+
for (const c of workspaceLocks(ctx.configDir))
|
|
268
|
+
await cancelCurrent(ctx, c, realDeps(ctx));
|
|
260
269
|
const c = readCurrent(ctx.configDir);
|
|
261
270
|
if (c)
|
|
262
271
|
await cancelCurrent(ctx, c, realDeps(ctx));
|
|
263
272
|
ctx.log('Runner stopped.');
|
|
264
273
|
return 0;
|
|
265
274
|
}
|
|
275
|
+
// The lock holds the round as the card's vs-vN label shows it; 0 before any round.
|
|
276
|
+
export function workingLine(current) {
|
|
277
|
+
if (!current)
|
|
278
|
+
return '';
|
|
279
|
+
if (!current.card)
|
|
280
|
+
return ' Checking now.';
|
|
281
|
+
return ` Working on ${current.card}${current.title ? ` "${current.title}"` : ''} (${[current.workflow, current.step, current.round ? `round ${current.round}` : '', `${current.elapsedMinutes}m`].filter(Boolean).join(', ')}).`;
|
|
282
|
+
}
|
|
283
|
+
const elapsed = (c) => (c.startedAt ? Math.max(0, Math.round((Date.now() - Date.parse(c.startedAt)) / 60_000)) : 0);
|
|
284
|
+
// One line per workspace: the card it is on, or idle.
|
|
285
|
+
export function workspaceLines(workspaces, running) {
|
|
286
|
+
const names = [...new Set([...workspaces, ...running.map((r) => workspaceOf(r))])];
|
|
287
|
+
return names.map((ws) => ` ${ws}:${workingLine(running.find((r) => workspaceOf(r) === ws) ?? null) || ' idle.'}`);
|
|
288
|
+
}
|
|
266
289
|
const status = guard(async (args, ctx) => {
|
|
267
290
|
const p = parse('status', args, { json: { type: 'boolean' } }, ctx);
|
|
268
291
|
if (typeof p === 'number')
|
|
@@ -271,8 +294,11 @@ const status = guard(async (args, ctx) => {
|
|
|
271
294
|
const runner = runnerState(ctx);
|
|
272
295
|
const text = readLog(ctx.configDir);
|
|
273
296
|
const lastTick = lastTickOf(text);
|
|
274
|
-
const live =
|
|
275
|
-
const
|
|
297
|
+
const live = liveRuns(ctx.configDir);
|
|
298
|
+
const running = live.map((c) => ({ ...c, workspace: workspaceOf(c), elapsedMinutes: elapsed(c) }));
|
|
299
|
+
const dispatcher = liveCurrent(ctx.configDir);
|
|
300
|
+
// The first run, or the dispatcher checking for work, for dashboards that know one run only.
|
|
301
|
+
const current = running.length ? { ...running[0], checking: false } : dispatcher ? { ...dispatcher, checking: true, elapsedMinutes: elapsed(dispatcher) } : null;
|
|
276
302
|
const seen = new Set();
|
|
277
303
|
const parked = [];
|
|
278
304
|
const interrupted = [];
|
|
@@ -288,20 +314,22 @@ const status = guard(async (args, ctx) => {
|
|
|
288
314
|
const workflows = Object.keys(config.workflows).length;
|
|
289
315
|
const projects = Object.keys(config.projects).length;
|
|
290
316
|
if (p.values.json) {
|
|
291
|
-
ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, parked, interrupted, workflows, projects }, null, 2));
|
|
317
|
+
ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, running, parked, interrupted, workflows, projects }, null, 2));
|
|
292
318
|
return 0;
|
|
293
319
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
: '';
|
|
320
|
+
// One workspace keeps the single line; several get a line each.
|
|
321
|
+
const workspaces = workspacesInUse(config);
|
|
322
|
+
const perWorkspace = runner !== 'stopped' && (workspaces.length > 1 || running.some((r) => !workspaces.includes(r.workspace)));
|
|
323
|
+
const working = perWorkspace ? '' : workingLine(current);
|
|
299
324
|
if (runner === 'running')
|
|
300
325
|
ctx.log(`Runner: running.${working}`);
|
|
301
326
|
else if (runner === 'paused')
|
|
302
327
|
ctx.log(`Runner: paused, picks up no new cards. vsf agents resume to continue.${working}`);
|
|
303
328
|
else
|
|
304
329
|
ctx.log('Runner: stopped. vsf agents start to begin.');
|
|
330
|
+
if (perWorkspace)
|
|
331
|
+
for (const line of workspaceLines(workspaces, running))
|
|
332
|
+
ctx.log(line);
|
|
305
333
|
if (lastTick)
|
|
306
334
|
ctx.log(`Last check: ${clock(new Date(lastTick.at))}, ${lastTick.line.replace(/\.$/, '') || 'no details'}.`);
|
|
307
335
|
for (const card of interrupted)
|
|
@@ -388,6 +416,20 @@ const run = guard(async (args, ctx) => {
|
|
|
388
416
|
}
|
|
389
417
|
return code;
|
|
390
418
|
});
|
|
419
|
+
// The lock a cancel ends, or why there is none. Every run's lock counts, stale
|
|
420
|
+
// ones included, so a dead run can be tidied up; with none, the dispatcher's
|
|
421
|
+
// own lock, a check in progress. Without a card it must be the only one.
|
|
422
|
+
export function pickCancel(locks, dispatcher, card) {
|
|
423
|
+
const all = locks.length ? locks : dispatcher ? [dispatcher] : [];
|
|
424
|
+
if (!all.length)
|
|
425
|
+
return card ? `${card} is not running.` : 'Nothing is running.';
|
|
426
|
+
const name = (l) => `${l.card ?? 'a check'}${l.workspace ? ` in ${l.workspace}` : ''}`;
|
|
427
|
+
if (card)
|
|
428
|
+
return all.find((l) => l.card === card) ?? `${card} is not running. Running: ${all.map(name).join(', ')}.`;
|
|
429
|
+
if (all.length > 1)
|
|
430
|
+
return `More than one card is running: ${all.map(name).join(', ')}. Name one: vsf agents cancel CARD.`;
|
|
431
|
+
return all[0];
|
|
432
|
+
}
|
|
391
433
|
const cancel = guard(async (args, ctx) => {
|
|
392
434
|
const p = parse('cancel', args, {}, ctx);
|
|
393
435
|
if (typeof p === 'number')
|
|
@@ -395,16 +437,12 @@ const cancel = guard(async (args, ctx) => {
|
|
|
395
437
|
const card = p.positionals[0]?.toUpperCase();
|
|
396
438
|
if (p.positionals.length > 1 || (card && !CARD_ID.test(card)))
|
|
397
439
|
return usageError('cancel', ctx, 'Give one card id like ENG-12.');
|
|
398
|
-
const
|
|
399
|
-
if (
|
|
400
|
-
ctx.log(
|
|
401
|
-
return card ? 1 : 0;
|
|
440
|
+
const found = pickCancel(workspaceLocks(ctx.configDir), readCurrent(ctx.configDir), card);
|
|
441
|
+
if (typeof found === 'string') {
|
|
442
|
+
ctx.log(found);
|
|
443
|
+
return found.startsWith('More than one') ? 2 : card ? 1 : 0;
|
|
402
444
|
}
|
|
403
|
-
|
|
404
|
-
ctx.log(`${c.card ?? 'Another card'} is running, not ${card}.`);
|
|
405
|
-
return 1;
|
|
406
|
-
}
|
|
407
|
-
await cancelCurrent(ctx, c, realDeps(ctx));
|
|
445
|
+
await cancelCurrent(ctx, found, realDeps(ctx));
|
|
408
446
|
return 0;
|
|
409
447
|
});
|
|
410
448
|
const retry = guard(async (args, ctx) => {
|
|
@@ -414,7 +452,7 @@ const retry = guard(async (args, ctx) => {
|
|
|
414
452
|
const id = p.positionals[0]?.toUpperCase();
|
|
415
453
|
if (p.positionals.length !== 1 || !CARD_ID.test(id))
|
|
416
454
|
return usageError('retry', ctx, 'Give one card id like ENG-12.');
|
|
417
|
-
if (
|
|
455
|
+
if (liveRuns(ctx.configDir).some((c) => c.card === id)) {
|
|
418
456
|
ctx.log(`${id} is running now. vsf agents cancel ${id} first.`);
|
|
419
457
|
return 1;
|
|
420
458
|
}
|
|
@@ -484,7 +522,7 @@ const runs = guard(async (args, ctx) => {
|
|
|
484
522
|
}
|
|
485
523
|
if (!entries.length)
|
|
486
524
|
ctx.log('No runs yet.');
|
|
487
|
-
const live =
|
|
525
|
+
const live = liveRuns(ctx.configDir);
|
|
488
526
|
for (const e of entries)
|
|
489
527
|
ctx.log(formatRunLine(e, Date.now(), live));
|
|
490
528
|
return 0;
|
|
@@ -518,7 +556,7 @@ const show = guard(async (args, ctx) => {
|
|
|
518
556
|
return 0;
|
|
519
557
|
}
|
|
520
558
|
ctx.log(`${latest.card} ${latest.title}`);
|
|
521
|
-
ctx.log(`Workflow ${latest.workflow}, ${isInterrupted(all[0],
|
|
559
|
+
ctx.log(`Workflow ${latest.workflow}, ${isInterrupted(all[0], liveRuns(ctx.configDir)) ? 'interrupted' : latest.outcome}${latest.prUrl ? `, PR ${latest.prUrl}` : ''}`);
|
|
522
560
|
if (labels)
|
|
523
561
|
ctx.log(`Labels: ${labels.join(', ') || 'none'}`);
|
|
524
562
|
for (const s of latest.steps) {
|
|
@@ -634,10 +672,10 @@ const clean = guard(async (args, ctx) => {
|
|
|
634
672
|
if (ms === null)
|
|
635
673
|
return usageError('clean', ctx, `${age} is not an age. Use a number of days or hours, like 14d or 36h.`);
|
|
636
674
|
const cutoff = Date.now() - ms;
|
|
637
|
-
const live =
|
|
675
|
+
const live = new Set(liveRuns(ctx.configDir).map((c) => c.card));
|
|
638
676
|
const wtRoot = join(agentsDir(ctx.configDir), 'worktrees');
|
|
639
677
|
const worktrees = (existsSync(wtRoot) ? readdirSync(wtRoot) : [])
|
|
640
|
-
.filter((card) =>
|
|
678
|
+
.filter((card) => !live.has(card))
|
|
641
679
|
.filter((card) => {
|
|
642
680
|
const record = listRuns(ctx.configDir, card)[0]?.record;
|
|
643
681
|
const ended = record?.endedAt ? Date.parse(record.endedAt) : mtime(join(wtRoot, card));
|
|
@@ -645,7 +683,7 @@ const clean = guard(async (args, ctx) => {
|
|
|
645
683
|
})
|
|
646
684
|
.map((card) => join(wtRoot, card));
|
|
647
685
|
const old = listRuns(ctx.configDir).filter((e) => {
|
|
648
|
-
if (e.record?.outcome === 'running' && e.card
|
|
686
|
+
if (e.record?.outcome === 'running' && live.has(e.card))
|
|
649
687
|
return false;
|
|
650
688
|
return (e.record?.endedAt ? Date.parse(e.record.endedAt) : mtime(e.dir)) < cutoff;
|
|
651
689
|
});
|
|
@@ -675,12 +713,17 @@ const clean = guard(async (args, ctx) => {
|
|
|
675
713
|
ctx.log(`Removed ${plural(worktrees.length, 'worktree')} and ${plural(old.length, 'run')}.`);
|
|
676
714
|
return 0;
|
|
677
715
|
});
|
|
678
|
-
// Internal: the runner (launchd or systemd) runs this every minute
|
|
679
|
-
|
|
716
|
+
// Internal: the runner (launchd or systemd) runs this every minute, and it
|
|
717
|
+
// starts itself with --card or --schedule for each run, one per workspace.
|
|
718
|
+
const tickCmd = async (args, ctx) => {
|
|
680
719
|
const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
|
|
681
720
|
try {
|
|
721
|
+
const { values } = parseArgs({ args, options: { card: { type: 'string' }, workflow: { type: 'string' }, schedule: { type: 'string' } }, strict: true });
|
|
682
722
|
const config = readAgentsConfig(ctx.configDir);
|
|
683
|
-
|
|
723
|
+
const rc = { configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand };
|
|
724
|
+
if (values.card || values.workflow)
|
|
725
|
+
return await tick(rc, values.card?.toUpperCase(), { workflow: values.workflow, schedule: values.schedule, child: true });
|
|
726
|
+
return await dispatch(rc, { reload: () => readAgentsConfig(ctx.configDir) });
|
|
684
727
|
}
|
|
685
728
|
catch (err) {
|
|
686
729
|
stamped(`Could not run the check: ${err.message}`);
|
|
@@ -723,8 +766,8 @@ export async function controlChecks(ctx) {
|
|
|
723
766
|
? { name: 'runner program', status: 'fail', detail: `runner points at a missing ${missing[0]}`, fix: 'vsf agents restart' }
|
|
724
767
|
: { name: 'runner program', status: 'ok', detail: 'found' });
|
|
725
768
|
}
|
|
726
|
-
const
|
|
727
|
-
if (c && !ours(c.pid))
|
|
769
|
+
const locks = [readCurrent(ctx.configDir), ...workspaceLocks(ctx.configDir)];
|
|
770
|
+
if (locks.some((c) => c && !ours(c.pid)))
|
|
728
771
|
checks.push({ name: 'stale lock', status: 'warn', detail: 'a stale lock from a run that died', fix: 'vsf agents cancel' });
|
|
729
772
|
if (existsSync(logPath(ctx.configDir))) {
|
|
730
773
|
const { count, last } = scanRunnerErrors(readLog(ctx.configDir), Date.now());
|