gcf-board 0.2.5 → 0.2.9

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 CHANGED
@@ -30,7 +30,7 @@ It puts **GCF Board** on the desktop (Windows: a shortcut whose server console s
30
30
 
31
31
  ## What it does
32
32
 
33
- - **Board**: columns ready, running, blocked, awaiting-verdict, awaiting-decision, unplayed, parked. A card shows the type, the gate, the plan progress (`2/5`), the severity, `verified`, the model.
33
+ - **Board**: columns ready, running, blocked, awaiting-verdict, awaiting-decision, unplayed, parked. A card shows the type, the gate, the plan progress (`2/5`), the severity, `verified`, the model. A running card shows when its run log was last written (`active 12 s ago`), and `quiet 6 min` when the log has been silent for 5 minutes or more. A run's end report gives each item's cost when the agent reported it.
34
34
  - **An item**: its file, its plan and its run log as it grows, and the moves the core offers for it (`items[].moves`, Methodology §4.1): verdict, decide, block, unblock, drop, close, submit. The board keeps only each move's form; the core still checks the rest itself, and the board shows its answer.
35
35
  - **Drag**:
36
36
  - a ready card into **running** starts its run;
@@ -42,7 +42,7 @@ It puts **GCF Board** on the desktop (Windows: a shortcut whose server console s
42
42
  - `run_mode: worktree`: one worktree and one agent session per item, all at once, then the integrator;
43
43
  - `run_mode: main`: one item at a time in the project folder. A run asked for meanwhile waits in the board's **queue**; **remove from queue** takes it out.
44
44
 
45
- A run asks first: it spends the subscription. **Stop** on a running item ends its session; the item stays ready and its work is kept. A queued main run that the next one would repeat (the harness did not start, or another run works the checkout) pauses the queue: the header shows **queue paused — resume**; fix the cause, then resume. A card marked **interrupted** had a run that stopped (a timeout, a stop, a crash): run it again and the run resumes its work. When a run ends, a toast lists each item's result. The queue lives in the board's memory: a board restart forgets it.
45
+ A run asks first: it spends the subscription. **Stop** on a running item ends its session; the item stays ready and its work is kept. A queued main run that the next one would repeat (the harness did not start, or another run works the checkout) pauses the queue: the header shows **queue paused — resume**; fix the cause, then resume. A card marked **interrupted** had a run that stopped (a timeout, a stop, a crash, the usage limit): **resume** continues its agent session with all its context, **start fresh** drops it. After the usage limit the card reads **paused: limit** and the board resumes it by itself at the reset time; the header shows **resumes HH:MM** with **cancel**. When a run ends, a toast lists each item's result. The queue lives in the board's memory: a board restart forgets it.
46
46
  - **Run next N independent**: the framework's gate picks the ready items that can run together now, by priority, and the board runs them after asking.
47
47
  - **Priority**: drop a ready card on another to put it before; the column's ranks are committed (`gcf prioritize`).
48
48
  - **Questions**: each D1 shows its question and proposed answer; **Answer** writes yours into its record (`gcf question answer`), and the next agent on its units puts it in place.
@@ -7,14 +7,14 @@ import { existsSync, statSync } from 'node:fs';
7
7
  import { readFile } from 'node:fs/promises';
8
8
  import path from 'node:path';
9
9
  import { BOARD_SCHEMA } from '../shared/board.js';
10
- import { admit, COOKIE, hostAllowed, tokenInUrl } from './auth.js';
10
+ import { admit, cookieName, hostAllowed, tokenInUrl } from './auth.js';
11
11
  import { briefError, runCli } from './cli.js';
12
- import { broadcast, subscribe, watchProject } from './events.js';
13
- import { followLog, readShown } from './files.js';
12
+ import { broadcast, planRev, subscribe, watchProject } from './events.js';
13
+ import { followLog, readShown, shownShape } from './files.js';
14
14
  import { HttpError } from './http-error.js';
15
15
  import { moveArgs, runIds } from './moves.js';
16
16
  import { addProject, listProjects, projectOf, removeProject } from './registry.js';
17
- import { resumeQueue, runsOf, startRun, unqueue } from './runs.js';
17
+ import { cancelResume, forgetProject, resumeQueue, runsOf, startRun, unqueue } from './runs.js';
18
18
  const send = (res, status, body) => {
19
19
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
20
20
  res.end(JSON.stringify(body));
@@ -49,6 +49,15 @@ function jsonOf(r, what) {
49
49
  throw new HttpError(502, `ai-factory ${what} gave no report: ${(r.err || r.out).trim()}`);
50
50
  }
51
51
  }
52
+ /** The ISO time a file was last written, or null when it is not there. */
53
+ const activeAt = (file) => {
54
+ try {
55
+ return statSync(file).mtime.toISOString();
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ };
52
61
  const TYPES = {
53
62
  '.html': 'text/html; charset=utf-8',
54
63
  '.js': 'text/javascript; charset=utf-8',
@@ -116,6 +125,7 @@ export function createApp(o) {
116
125
  /^\/api\/projects\/([^/]+)$/,
117
126
  async (_req, res, m) => {
118
127
  await removeProject(m[1]);
128
+ forgetProject(m[1]);
119
129
  watchers.get(m[1])?.();
120
130
  watchers.delete(m[1]);
121
131
  send(res, 200, { ok: true });
@@ -126,8 +136,15 @@ export function createApp(o) {
126
136
  /^\/api\/p\/([^/]+)\/board$/,
127
137
  async (_req, res, m) => {
128
138
  const p = await projectOf(m[1]);
129
- // The board's own runs ride along: what it started and what waits in its queue.
130
- send(res, 200, { ...(await boardOf(p)), runs: runsOf(p.name) });
139
+ // The board's own runs ride along: what it started and what waits in its queue; and the
140
+ // count of plan writes, which tells the page an open plan's text changed.
141
+ const own = { runs: runsOf(p.name), plans_rev: planRev(p.name) };
142
+ const board = await boardOf(p);
143
+ // A running item's last log write tells a working session from a stuck one.
144
+ for (const i of board.items)
145
+ if (i.running)
146
+ i.active_at = i.log ? activeAt(path.join(p.path, i.log)) : null;
147
+ send(res, 200, { ...board, ...own });
131
148
  },
132
149
  ],
133
150
  [
@@ -135,7 +152,12 @@ export function createApp(o) {
135
152
  /^\/api\/p\/([^/]+)\/file$/,
136
153
  async (_req, res, m, url) => {
137
154
  const p = await projectOf(m[1]);
138
- send(res, 200, { text: await readShown(p.path, url.searchParams.get('path') ?? '') });
155
+ const rel = url.searchParams.get('path') ?? '';
156
+ // A plan an item links (`plan:`) may lie anywhere in the project: the core names it.
157
+ const linked = !shownShape(rel) &&
158
+ // A board the core cannot read links nothing: the path is refused, not a 500.
159
+ (await boardOf(p).catch(() => ({ items: [] }))).items.some((i) => i.plan_file === rel);
160
+ send(res, 200, { text: await readShown(p.path, rel, linked) });
139
161
  },
140
162
  ],
141
163
  [
@@ -184,8 +206,9 @@ export function createApp(o) {
184
206
  /^\/api\/p\/([^/]+)\/run$/,
185
207
  async (req, res, m) => {
186
208
  const p = await projectOf(m[1]);
187
- const ids = runIds(await bodyOf(req));
188
- send(res, 202, startRun(o.entry, p, ids, (await boardOf(p)).run_mode));
209
+ const body = await bodyOf(req);
210
+ const mode = (await boardOf(p)).run_mode;
211
+ send(res, 202, startRun(o.entry, p, runIds(body), mode, body.fresh === true));
189
212
  },
190
213
  ],
191
214
  [
@@ -217,6 +240,15 @@ export function createApp(o) {
217
240
  send(res, 200, { ok: true });
218
241
  },
219
242
  ],
243
+ [
244
+ // The resume a usage limit scheduled; the queue stays paused.
245
+ 'DELETE',
246
+ /^\/api\/p\/([^/]+)\/schedule$/,
247
+ async (_req, res, m) => {
248
+ cancelResume((await projectOf(m[1])).name);
249
+ send(res, 200, { ok: true });
250
+ },
251
+ ],
220
252
  [
221
253
  'DELETE',
222
254
  /^\/api\/p\/([^/]+)\/queue\/([a-z0-9][a-z0-9-]*)$/,
@@ -234,7 +266,7 @@ export function createApp(o) {
234
266
  const visit = req.method === 'GET' && !url.pathname.startsWith('/api/');
235
267
  if (visit && hostAllowed(req, o.port) && tokenInUrl(url, o.token)) {
236
268
  res.writeHead(302, {
237
- 'set-cookie': `${COOKIE}=${session}; HttpOnly; SameSite=Strict; Path=/`,
269
+ 'set-cookie': `${cookieName(o.port)}=${session}; HttpOnly; SameSite=Strict; Path=/`,
238
270
  // Always the root: a path like `//elsewhere` would send the browser off the board.
239
271
  location: '/',
240
272
  });
@@ -8,7 +8,9 @@ import { mkdir, readFile } from 'node:fs/promises';
8
8
  import path from 'node:path';
9
9
  import { writeAtomic } from './atomic.js';
10
10
  import { homeDir } from './registry.js';
11
- export const COOKIE = 'gcf_board';
11
+ // A browser keeps one cookie per name for all of 127.0.0.1: the port in the name lets two boards
12
+ // on two ports stay signed in side by side.
13
+ export const cookieName = (port) => `gcf_board_${port}`;
12
14
  // An empty or short token would let everyone in: such a file is replaced.
13
15
  const STRONG = /^[0-9a-f]{48}$/;
14
16
  /** The token of this machine's board: made once, kept in `~/.gcf/board-token`. */
@@ -23,10 +25,10 @@ export async function boardToken() {
23
25
  return token;
24
26
  }
25
27
  const same = (a, b) => b.length > 0 && a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
26
- function cookieOf(req) {
28
+ function cookieOf(req, name) {
27
29
  for (const part of (req.headers.cookie ?? '').split(';')) {
28
30
  const [k, ...v] = part.trim().split('=');
29
- if (k === COOKIE)
31
+ if (k === name)
30
32
  return v.join('=');
31
33
  }
32
34
  return '';
@@ -42,7 +44,7 @@ export function admit(req, port, token) {
42
44
  const origin = req.headers.origin;
43
45
  if (write && !(origin && hosts.some((h) => origin === `http://${h}`)))
44
46
  return { ok: false, status: 403, message: 'a change comes from the board page only' };
45
- if (!same(cookieOf(req), token))
47
+ if (!same(cookieOf(req, cookieName(port)), token))
46
48
  return { ok: false, status: 401, message: 'open the URL gcf-board printed at start' };
47
49
  return { ok: true };
48
50
  }
@@ -11,11 +11,15 @@ export function openInBrowser(url) {
11
11
  : process.platform === 'darwin'
12
12
  ? ['open', [url]]
13
13
  : ['xdg-open', [url]];
14
+ // No browser to open: the operator opens the printed URL. A missing opener fails as an event,
15
+ // which unheard would end the server.
14
16
  try {
15
- spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
17
+ spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true })
18
+ .on('error', () => { })
19
+ .unref();
16
20
  }
17
21
  catch {
18
- // No browser to open: the operator opens the printed URL.
22
+ // Refused at once: the same.
19
23
  }
20
24
  }
21
25
  /** A PowerShell single-quoted string: a quote inside is doubled. */
@@ -1,6 +1,6 @@
1
- // Server-sent events: a project's `gcf/` or its run locks changed, a run the board started ended.
2
- // The page reads the board again on each one; the event carries no state of its own.
3
- import { watch, watchFile, unwatchFile } from 'node:fs';
1
+ // Server-sent events: a project's `gcf/`, its plans or its run marks changed, a run the board
2
+ // started ended. The page reads the board again on each one; the event carries no state of its own.
3
+ import { readdirSync, readFileSync, statSync, watch } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  const clients = new Set();
6
6
  /** Open an event stream on `res`. */
@@ -25,10 +25,64 @@ export function broadcast(event) {
25
25
  for (const c of clients)
26
26
  c.write(line);
27
27
  }
28
+ // The core's SESSION_TTL_MS (session-mark.js): a mark nobody refreshed for this long is a session
29
+ // that stopped. shortcut: a copy of the core's constant, read it from the board JSON once the core
30
+ // reports a mark's expiry.
31
+ const SESSION_TTL_MS = 120 * 60_000;
32
+ const LOG_STEP_MS = 10_000;
33
+ const alive = (pid) => {
34
+ if (!Number.isInteger(pid) || pid <= 0)
35
+ return false;
36
+ try {
37
+ process.kill(pid, 0);
38
+ return true;
39
+ }
40
+ catch (e) {
41
+ return e.code === 'EPERM';
42
+ }
43
+ };
44
+ /**
45
+ * What makes items `running` in a runs directory: its entries, whether each lock's process
46
+ * lives, whether each session mark is fresh; and how recently each run log was written. A run
47
+ * that died or a session that expired changes it with no file written.
48
+ */
49
+ export function runsState(dir, now = Date.now()) {
50
+ let names;
51
+ try {
52
+ names = readdirSync(dir).sort();
53
+ }
54
+ catch {
55
+ return '';
56
+ }
57
+ return names
58
+ .map((n) => {
59
+ const file = path.join(dir, n);
60
+ try {
61
+ if (n.endsWith('.lock'))
62
+ return `${n} ${alive(Number(readFileSync(file, 'utf8').split('\n')[0]))}`;
63
+ if (n.endsWith('.session'))
64
+ return `${n} ${now - statSync(file).mtimeMs < SESSION_TTL_MS}`;
65
+ // A session writes its log many times a second: its card's activity time moves by steps
66
+ // of LOG_STEP_MS, each one a board read, not one per line.
67
+ if (n.endsWith('.log'))
68
+ return `${n} ${Math.floor(statSync(file).mtimeMs / LOG_STEP_MS)}`;
69
+ }
70
+ catch {
71
+ // Gone meanwhile: the next poll sees it.
72
+ }
73
+ return n;
74
+ })
75
+ .join('\n');
76
+ }
77
+ // Each project's plan writes, counted: a plan's text can change while its checkbox progress stays,
78
+ // so the page reads an open plan again when the count moves.
79
+ const planRevs = new Map();
80
+ export const planRev = (project) => planRevs.get(project) ?? 0;
28
81
  /**
29
- * Watch a project: `gcf/` (every move writes there) and `.gcf-session/runs` (a run's lock comes
30
- * and goes). The runs directory may not exist yet, so it is polled, not watched. Bursts of
31
- * writes make one event. Returns the stop.
82
+ * Watch a project: `gcf/` (every move writes there), `.ai-factory/plans` (a plan is written and
83
+ * edited) and `.gcf-session/runs` (a run's lock or session mark comes, goes or goes stale). The
84
+ * runs directory may not exist yet, so it is polled, not watched. Bursts of writes make one
85
+ * event. Returns the stop.
32
86
  */
33
87
  export function watchProject(p, debounceMs = 300) {
34
88
  let timer = null;
@@ -37,19 +91,38 @@ export function watchProject(p, debounceMs = 300) {
37
91
  clearTimeout(timer);
38
92
  timer = setTimeout(() => broadcast({ type: 'board', project: p.name }), debounceMs);
39
93
  };
40
- let gcf = null;
41
- try {
42
- gcf = watch(path.join(p.path, 'gcf'), { recursive: true }, fire);
43
- gcf.on('error', () => gcf?.close());
44
- }
45
- catch {
46
- // gcf/ is gone: the board read shows the error, nothing to watch.
47
- }
94
+ const watchers = [];
95
+ const watchDir = (dir, onChange) => {
96
+ try {
97
+ const w = watch(dir, { recursive: true }, (_e, file) => onChange(file));
98
+ w.on('error', () => w.close());
99
+ watchers.push(w);
100
+ }
101
+ catch {
102
+ // Not there: the board read shows what is missing.
103
+ }
104
+ };
105
+ watchDir(path.join(p.path, 'gcf'), fire);
106
+ // shortcut: a plan an item links outside .ai-factory/plans shows its new text only when its
107
+ // checkbox count moves; watch those files too if operators edit such plans by hand.
108
+ watchDir(path.join(p.path, '.ai-factory'), (file) => {
109
+ if (!/^plans[\\/]/.test(file ?? ''))
110
+ return;
111
+ planRevs.set(p.name, planRev(p.name) + 1);
112
+ fire();
113
+ });
48
114
  const runs = path.join(p.path, '.gcf-session', 'runs');
49
- watchFile(runs, { interval: 1500 }, fire);
115
+ let last = runsState(runs);
116
+ const poll = setInterval(() => {
117
+ const now = runsState(runs);
118
+ if (now !== last)
119
+ fire();
120
+ last = now;
121
+ }, 1500);
50
122
  return () => {
51
- gcf?.close();
52
- unwatchFile(runs, fire);
123
+ for (const w of watchers)
124
+ w.close();
125
+ clearInterval(poll);
53
126
  if (timer)
54
127
  clearTimeout(timer);
55
128
  };
@@ -1,5 +1,5 @@
1
- // The files a board shows: an item, its plan, its run log. Only those shapes, only inside the
2
- // project, symlinks resolved: the API is not a way to read the disk.
1
+ // The files a board shows: an item, its plan, its run log. Only those shapes (or a plan an item
2
+ // links), only inside the project, symlinks resolved: the API is not a way to read the disk.
3
3
  import { realpath, readFile, stat, open } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { StringDecoder } from 'node:string_decoder';
@@ -11,9 +11,11 @@ const SHOWN = [
11
11
  /^\.gcf-session\/runs\/[\w.-]+\.log$/,
12
12
  ];
13
13
  const MAX = 1024 * 1024;
14
- /** The absolute path of a shown file, or a 403. */
15
- export async function shownFile(root, rel) {
16
- if (!SHOWN.some((r) => r.test(rel)) || rel.split('/').includes('..'))
14
+ /** Whether `rel` has a shape the board shows without asking the core. */
15
+ export const shownShape = (rel) => SHOWN.some((r) => r.test(rel));
16
+ /** The absolute path of a shown file (or a plan an item links, `linked`), or a 403. */
17
+ export async function shownFile(root, rel, linked = false) {
18
+ if (!(linked || shownShape(rel)) || rel.split('/').includes('..'))
17
19
  throw new HttpError(403, `${rel} is not a file the board shows`);
18
20
  const file = path.join(root, rel);
19
21
  const real = await realpath(file).catch(() => {
@@ -25,8 +27,8 @@ export async function shownFile(root, rel) {
25
27
  return real;
26
28
  }
27
29
  /** The text of a shown file; a large one is cut to its last megabyte. */
28
- export async function readShown(root, rel) {
29
- const file = await shownFile(root, rel);
30
+ export async function readShown(root, rel, linked = false) {
31
+ const file = await shownFile(root, rel, linked);
30
32
  const { size } = await stat(file);
31
33
  if (size <= MAX)
32
34
  return readFile(file, 'utf8');
@@ -12,13 +12,123 @@ const active = new Set();
12
12
  const queues = new Map();
13
13
  // Projects whose queue waits after a failure the next run would repeat (`stalled`).
14
14
  const paused = new Set();
15
+ // shortcut: the scheduled resume after a usage limit lives in memory, like the queue: a board
16
+ // restart loses it, and the operator resumes by hand; persist it if restarts become common.
17
+ const scheduled = new Map();
18
+ // The paused projects whose pause the usage limit set: its reset may lift it, nothing else.
19
+ const limitPaused = new Set();
20
+ // Runs going on for a project the operator removed: their end touches no project state.
21
+ const forgotten = new WeakSet();
15
22
  export const activeRuns = (project) => [...active].filter((r) => r.project === project);
16
23
  /** What runs and what waits, for a project; a paused queue waits for `resumeQueue`. */
17
- export const runsOf = (project) => ({
18
- active: activeRuns(project),
19
- queued: (queues.get(project) ?? []).flatMap((q) => q.ids),
20
- paused: paused.has(project),
21
- });
24
+ export function runsOf(project) {
25
+ const s = scheduled.get(project);
26
+ return {
27
+ active: activeRuns(project),
28
+ queued: (queues.get(project) ?? []).flatMap((q) => q.ids),
29
+ paused: paused.has(project),
30
+ // When the items a usage limit stopped run again by themselves, and the queue with them.
31
+ resume: s ? { at: s.at.toISOString(), ids: s.run.ids } : null,
32
+ };
33
+ }
34
+ /**
35
+ * The time a usage limit resets, as Claude names it (`3am`, `3:30pm`, `15:00`, with or without
36
+ * `(Europe/Berlin)`): its next occurrence after `now`, a minute late to be safe; null when the
37
+ * text is no such time.
38
+ */
39
+ export function resetAt(text, now = new Date()) {
40
+ // shortcut: the time is read on this machine's clock and the zone it names is ignored: the
41
+ // board runs where the operator's claude does; convert through Intl if a zone ever differs.
42
+ const m = /^\s*(\d{1,2})(?::(\d{2}))?\s*([ap]m)?\s*(\(.*\))?\s*$/i.exec(text);
43
+ if (!m || (!m[2] && !m[3]))
44
+ return null;
45
+ let hour = Number(m[1]);
46
+ const minute = Number(m[2] ?? 0);
47
+ if (m[3]) {
48
+ if (hour < 1 || hour > 12)
49
+ return null;
50
+ hour = (hour % 12) + (m[3].toLowerCase() === 'pm' ? 12 : 0);
51
+ }
52
+ if (hour > 23 || minute > 59)
53
+ return null;
54
+ const at = new Date(now);
55
+ at.setHours(hour, minute + 1, 0, 0);
56
+ if (at <= now)
57
+ at.setDate(at.getDate() + 1);
58
+ return at;
59
+ }
60
+ /** Drop the scheduled resume; the queue stays as it is. */
61
+ export function cancelResume(project) {
62
+ clearTimeout(scheduled.get(project)?.timer);
63
+ scheduled.delete(project);
64
+ broadcast({ type: 'board', project });
65
+ }
66
+ /** Lift a pause the usage limit set; a pause for another cause waits for the operator. */
67
+ function liftLimitPause(project) {
68
+ if (limitPaused.delete(project))
69
+ paused.delete(project);
70
+ }
71
+ /** A project left the board: nothing of it may run or fire any more. */
72
+ export function forgetProject(project) {
73
+ for (const r of activeRuns(project))
74
+ forgotten.add(r);
75
+ clearTimeout(scheduled.get(project)?.timer);
76
+ scheduled.delete(project);
77
+ queues.delete(project);
78
+ paused.delete(project);
79
+ limitPaused.delete(project);
80
+ }
81
+ /**
82
+ * At the reset: run the stopped items again (the harness resumes their sessions), then the queue.
83
+ * A second limited run joins the first: all their items go, at the later reset.
84
+ */
85
+ function schedule(run, at) {
86
+ const project = run.p.name;
87
+ const before = scheduled.get(project);
88
+ clearTimeout(before?.timer);
89
+ const ids = [...new Set([...(before?.run.ids ?? []), ...run.ids])];
90
+ const when = before && before.at > at ? before.at : at;
91
+ const timer = setTimeout(() => {
92
+ const s = scheduled.get(project);
93
+ scheduled.delete(project);
94
+ liftLimitPause(project);
95
+ try {
96
+ if (s)
97
+ startRun(s.run.entry, s.run.p, s.run.ids, s.run.mode);
98
+ }
99
+ catch {
100
+ // Started from the board meanwhile: the queue still goes on after it.
101
+ advance(project);
102
+ }
103
+ broadcast({ type: 'board', project });
104
+ }, when.getTime() - Date.now());
105
+ timer.unref();
106
+ scheduled.set(project, { at: when, run: { ...run, ids }, timer });
107
+ }
108
+ /**
109
+ * A run some of whose items hit the usage limit: the main queue would hit it too, so it pauses,
110
+ * and those items run again at the reset when the message names a time. The reason, for its
111
+ * `run` event.
112
+ */
113
+ function limited(run, result) {
114
+ const items = result
115
+ ?.items;
116
+ const hit = (items ?? []).filter((i) => i.limit_resets !== undefined);
117
+ if (!hit.length)
118
+ return '';
119
+ const project = run.p.name;
120
+ if (run.mode === 'main') {
121
+ paused.add(project);
122
+ limitPaused.add(project);
123
+ }
124
+ const resets = hit.find((i) => i.limit_resets)?.limit_resets ?? '';
125
+ const at = resetAt(resets);
126
+ if (at)
127
+ schedule({ ...run, ids: hit.map((i) => i.id), fresh: false }, at);
128
+ const when = at ? `they resume at ${at.toTimeString().slice(0, 5)}` : 'run them after the reset';
129
+ const queue = run.mode === 'main' ? ', the queue is paused' : '';
130
+ return `usage limit${resets ? `, resets ${resets}` : ''}${queue}: ${when}`;
131
+ }
22
132
  /** Start the next queued batch when the checkout is free: after a run, or on the operator's resume. */
23
133
  function advance(project) {
24
134
  if (paused.has(project) || activeRuns(project).some((r) => r.mode === 'main'))
@@ -30,6 +140,7 @@ function advance(project) {
30
140
  /** The operator resumes a paused queue (after fixing what stopped it). */
31
141
  export function resumeQueue(project) {
32
142
  paused.delete(project);
143
+ limitPaused.delete(project);
33
144
  advance(project);
34
145
  broadcast({ type: 'board', project });
35
146
  }
@@ -52,10 +163,12 @@ export function stalled(ok, result) {
52
163
  const items = result?.items ?? [];
53
164
  return (items.length > 0 && items.every((i) => /another run works in the main/.test(i.error ?? '')));
54
165
  }
55
- function spawnRun({ entry, p, ids }, mode) {
166
+ function spawnRun(q, mode) {
167
+ const { entry, p, ids } = q;
56
168
  const run = { project: p.name, ids, started: new Date().toISOString(), mode };
57
169
  active.add(run);
58
- const child = spawn(process.execPath, [entry, 'gcf-harness', 'run', ...ids, '--json'], {
170
+ const args = [entry, 'gcf-harness', 'run', ...ids, '--json', ...(q.fresh ? ['--fresh'] : [])];
171
+ const child = spawn(process.execPath, args, {
59
172
  cwd: p.path,
60
173
  shell: false,
61
174
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -77,10 +190,17 @@ function spawnRun({ entry, p, ids }, mode) {
77
190
  catch {
78
191
  // No report: the error says why.
79
192
  }
80
- const stop = mode === 'main' && queues.get(p.name)?.length && stalled(ok, result);
81
- if (stop)
193
+ // A run of a project removed meanwhile only reports: it pauses and schedules nothing.
194
+ const kept = !forgotten.has(run);
195
+ const stop = kept && mode === 'main' && queues.get(p.name)?.length && stalled(ok, result);
196
+ if (stop) {
82
197
  paused.add(p.name);
83
- const why = stop ? '\nthe queue is paused: fix the cause, then resume it on the board' : '';
198
+ limitPaused.delete(p.name);
199
+ }
200
+ const note = !kept
201
+ ? ''
202
+ : limited(q, result) ||
203
+ (stop ? 'the queue is paused: fix the cause, then resume it on the board' : '');
84
204
  const brief = err && briefError(err);
85
205
  broadcast({
86
206
  type: 'run',
@@ -88,8 +208,10 @@ function spawnRun({ entry, p, ids }, mode) {
88
208
  ids,
89
209
  ok,
90
210
  result,
91
- err: (brief + message + why).trim(),
211
+ err: (brief + message).trim(),
212
+ note,
92
213
  });
214
+ // The checkout is free now, for whatever queue the name has: a project added back meanwhile.
93
215
  if (mode === 'main')
94
216
  advance(p.name);
95
217
  broadcast({ type: 'board', project: p.name });
@@ -100,18 +222,29 @@ function spawnRun({ entry, p, ids }, mode) {
100
222
  }
101
223
  /**
102
224
  * Start a batch in the project's run mode, or queue it behind the main run going on. Its end
103
- * is a `run` event with the harness report, or what went wrong.
225
+ * is a `run` event with the harness report, or what went wrong. `fresh` starts new agent
226
+ * sessions where the harness would resume the interrupted ones.
104
227
  */
105
- export function startRun(entry, p, ids, mode = 'worktree') {
228
+ export function startRun(entry, p, ids, mode = 'worktree', fresh = false) {
106
229
  // A second click on the same card: the harness would refuse it on its lock, noisily.
107
230
  const { active: going, queued } = runsOf(p.name);
108
231
  const busy = ids.filter((id) => queued.includes(id) || going.some((r) => r.ids.includes(id)));
109
232
  if (busy.length)
110
233
  throw new HttpError(409, `already running from this board: ${busy.join(', ')}`);
111
- if (mode === 'main' && going.some((r) => r.mode === 'main')) {
112
- queues.set(p.name, [...(queues.get(p.name) ?? []), { entry, p, ids, mode }]);
234
+ // The operator runs an item the limit stopped before its reset: they know better, so it goes
235
+ // now, and the limit's pause with it; the rest of the schedule stays.
236
+ const s = scheduled.get(p.name);
237
+ if (s?.run.ids.some((id) => ids.includes(id))) {
238
+ s.run = { ...s.run, ids: s.run.ids.filter((id) => !ids.includes(id)) };
239
+ if (!s.run.ids.length)
240
+ cancelResume(p.name);
241
+ liftLimitPause(p.name);
242
+ }
243
+ // A paused queue holds a new main run too: it would repeat the failure, and jump the queue.
244
+ if (mode === 'main' && (paused.has(p.name) || going.some((r) => r.mode === 'main'))) {
245
+ queues.set(p.name, [...(queues.get(p.name) ?? []), { entry, p, ids, mode, fresh }]);
113
246
  broadcast({ type: 'board', project: p.name });
114
247
  return { queued: ids };
115
248
  }
116
- return { started: spawnRun({ entry, p, ids, mode }, mode) };
249
+ return { started: spawnRun({ entry, p, ids, mode, fresh }, mode) };
117
250
  }