gcf-board 0.2.6 → 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.
@@ -14,7 +14,7 @@ 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 });
@@ -129,7 +139,12 @@ export function createApp(o) {
129
139
  // The board's own runs ride along: what it started and what waits in its queue; and the
130
140
  // count of plan writes, which tells the page an open plan's text changed.
131
141
  const own = { runs: runsOf(p.name), plans_rev: planRev(p.name) };
132
- send(res, 200, { ...(await boardOf(p)), ...own });
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 });
133
148
  },
134
149
  ],
135
150
  [
@@ -191,8 +206,9 @@ export function createApp(o) {
191
206
  /^\/api\/p\/([^/]+)\/run$/,
192
207
  async (req, res, m) => {
193
208
  const p = await projectOf(m[1]);
194
- const ids = runIds(await bodyOf(req));
195
- 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));
196
212
  },
197
213
  ],
198
214
  [
@@ -224,6 +240,15 @@ export function createApp(o) {
224
240
  send(res, 200, { ok: true });
225
241
  },
226
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
+ ],
227
252
  [
228
253
  'DELETE',
229
254
  /^\/api\/p\/([^/]+)\/queue\/([a-z0-9][a-z0-9-]*)$/,
@@ -29,6 +29,7 @@ export function broadcast(event) {
29
29
  // that stopped. shortcut: a copy of the core's constant, read it from the board JSON once the core
30
30
  // reports a mark's expiry.
31
31
  const SESSION_TTL_MS = 120 * 60_000;
32
+ const LOG_STEP_MS = 10_000;
32
33
  const alive = (pid) => {
33
34
  if (!Number.isInteger(pid) || pid <= 0)
34
35
  return false;
@@ -42,8 +43,8 @@ const alive = (pid) => {
42
43
  };
43
44
  /**
44
45
  * What makes items `running` in a runs directory: its entries, whether each lock's process
45
- * lives, whether each session mark is fresh. A run that died or a session that expired changes
46
- * it with no file written.
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.
47
48
  */
48
49
  export function runsState(dir, now = Date.now()) {
49
50
  let names;
@@ -61,6 +62,10 @@ export function runsState(dir, now = Date.now()) {
61
62
  return `${n} ${alive(Number(readFileSync(file, 'utf8').split('\n')[0]))}`;
62
63
  if (n.endsWith('.session'))
63
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)}`;
64
69
  }
65
70
  catch {
66
71
  // Gone meanwhile: the next poll sees it.
@@ -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,19 +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(', ')}`);
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
+ }
111
243
  // A paused queue holds a new main run too: it would repeat the failure, and jump the queue.
112
244
  if (mode === 'main' && (paused.has(p.name) || going.some((r) => r.mode === 'main'))) {
113
- queues.set(p.name, [...(queues.get(p.name) ?? []), { entry, p, ids, mode }]);
245
+ queues.set(p.name, [...(queues.get(p.name) ?? []), { entry, p, ids, mode, fresh }]);
114
246
  broadcast({ type: 'board', project: p.name });
115
247
  return { queued: ids };
116
248
  }
117
- return { started: spawnRun({ entry, p, ids, mode }, mode) };
249
+ return { started: spawnRun({ entry, p, ids, mode, fresh }, mode) };
118
250
  }