great-cto 3.62.0 → 3.62.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
- "version": "3.62.0",
4
+ "version": "3.62.2",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -0,0 +1,3 @@
1
+ // Fixed test-only parent, killed after the child-start handshake.
2
+ import { getTasksAsync } from '../lib/beads.mjs';
3
+ await getTasksAsync(process.cwd());
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ // Fixed local test protocol. No shell commands or operator stores.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ const root = process.env.FAKE_BD_CONTROL_ROOT;
7
+ if (!root || !path.isAbsolute(root)) process.exit(2);
8
+ const key = path.basename(process.cwd());
9
+ const marker = name => path.join(root, `${key}.${name}`);
10
+ if (process.argv.includes('--descendant')) {
11
+ process.on('SIGTERM', () => {});
12
+ fs.writeFileSync(marker('descendant'), String(process.pid));
13
+ setInterval(() => {}, 1000);
14
+ } else {
15
+ fs.appendFileSync(marker('started'), `${process.pid}\n`);
16
+ fs.writeFileSync(marker('worker'), String(process.ppid));
17
+ if (process.env.FAKE_BD_CONTROL_MODE === 'fail') {
18
+ process.exit(1);
19
+ } else if (['gate-receipt', 'gate-receipt-race'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
20
+ const tasks = [{ id: 'FIXTURE-GATE', title: 'gate:ship', labels: ['gate'], status: 'open', priority: 0 }];
21
+ if (process.env.FAKE_BD_CONTROL_MODE === 'gate-receipt-race') tasks.push({ id: 'NEW-GATE', title: 'gate:plan', labels: ['gate'], status: 'open', priority: 0 });
22
+ process.stdout.write(JSON.stringify(tasks), () => process.exit(0));
23
+ } else if (['hang', 'leader-exit'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
24
+ process.on('SIGTERM', () => {});
25
+ spawn(process.execPath, [new URL(import.meta.url).pathname, '--descendant'], { stdio: 'inherit' });
26
+ if (process.env.FAKE_BD_CONTROL_MODE === 'leader-exit') {
27
+ setInterval(() => { if (fs.existsSync(marker('descendant'))) process.exit(0); }, 25);
28
+ }
29
+ setInterval(() => {}, 1000);
30
+ } else if (process.env.FAKE_BD_CONTROL_MODE === 'large-success') {
31
+ process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'x'.repeat(131072), status: 'open', priority: 0 }]), () => {
32
+ fs.writeFileSync(marker('output-written'), 'ready');process.exit(0);
33
+ });
34
+ } else if (['closed-success', 'closed-fail'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
35
+ const descendant = spawn(process.execPath, [new URL(import.meta.url).pathname, '--descendant'], { stdio: 'ignore' });
36
+ descendant.unref();
37
+ const ready = setInterval(() => {
38
+ if (!fs.existsSync(marker('descendant'))) return;
39
+ clearInterval(ready);
40
+ process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'Fixture task', status: 'open', priority: 0 }]),
41
+ () => process.exit(process.env.FAKE_BD_CONTROL_MODE === 'closed-fail' ? 1 : 0));
42
+ }, 25);
43
+ } else if (process.env.FAKE_BD_CONTROL_MODE === 'oversize') {
44
+ process.stdout.write(Buffer.alloc(16 * 1024 * 1024 + 1, 120));
45
+ setInterval(() => {}, 1000);
46
+ } else {
47
+ const timer = setInterval(() => {
48
+ if (!fs.existsSync(marker('release'))) return;
49
+ clearInterval(timer);
50
+ process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'Fixture task', status: 'open', priority: 0 }]));
51
+ }, 25);
52
+ }
53
+ }
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ // A fixed Git-read fixture. It never reads operator stores or invokes a shell.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ const root = process.env.GCTO_RECEIPT_CONTROL_ROOT, binary = process.env.GCTO_RECEIPT_REAL_GIT;
7
+ if (!root || !path.isAbsolute(root) || !binary || !path.isAbsolute(binary)) process.exit(2);
8
+ const args = process.argv.slice(2);
9
+ const run = () => {
10
+ const result = spawnSync(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
11
+ if (result.stdout) process.stdout.write(result.stdout);
12
+ if (result.stderr) process.stderr.write(result.stderr);
13
+ process.exit(result.status ?? 1);
14
+ };
15
+ if (args.includes('hash-object') || args.includes('--show-object-format') ||
16
+ (args.includes('rev-parse') && args.includes('HEAD') && fs.existsSync(path.join(root, 'hold-head')))) {
17
+ fs.appendFileSync(path.join(root, 'git-held'), `${process.pid}\n`);
18
+ const timer = setInterval(() => {
19
+ if (fs.existsSync(path.join(root, 'git-release'))) { clearInterval(timer); run(); }
20
+ }, 25);
21
+ } else run();
@@ -27,7 +27,7 @@ import { getCostHistory, getInbox } from './data-readers.mjs';
27
27
  // reporting "live · synced just now". `sweep: true` must NEVER be added to
28
28
  // an interactive read's opts: an ordinary request is someone waiting on
29
29
  // THIS project's answer, and the fill path answers `[]` instead.
30
- import { getTasks, SWEEP_MAX_AGE_MS } from './beads.mjs';
30
+ import { getTasks, getTasksAsync, getReadDegradation, SWEEP_MAX_AGE_MS } from './beads.mjs';
31
31
  const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS, sweep: true };
32
32
  import { readVerdicts } from './verdicts.mjs';
33
33
  import { isFailure } from './fleet.mjs';
@@ -303,11 +303,16 @@ function startAlertCron() {
303
303
  }, FIVE_MIN);
304
304
 
305
305
  // cost.threshold: monthly LLM spend at 80% / 100% of budget
306
- setInterval(() => {
306
+ setInterval(async () => {
307
307
  try {
308
308
  const projects = listProjects();
309
309
  for (const proj of projects) {
310
- const m = getMetrics(proj.path);
310
+ let tasks;
311
+ try {
312
+ tasks = await getTasksAsync(proj.path);
313
+ if (getReadDegradation(proj.path)) continue;
314
+ } catch { continue; } // Unknown spend must not be treated as zero.
315
+ const m = getMetrics(proj.path, 30, { tasks });
311
316
  const meta = readProjectMd(proj.path) || {};
312
317
  const budget = parseFloat(meta['monthly-budget']?.replace?.(/[$\s]/g, '') || '0');
313
318
  if (!budget) continue;
@@ -356,7 +361,12 @@ function startAlertCron() {
356
361
  // next project reads alerts-fired.json. Prevents the race condition where
357
362
  // all N projects read an empty map simultaneously and all fire.
358
363
  for (const proj of projects) {
359
- const m = getMetrics(proj.path);
364
+ let tasks;
365
+ try {
366
+ tasks = await getTasksAsync(proj.path);
367
+ if (getReadDegradation(proj.path)) continue;
368
+ } catch { continue; } // Unknown is not a zero-activity weekly report.
369
+ const m = getMetrics(proj.path, 30, { tasks });
360
370
  const dedupeKey = `digest.weekly:${proj.slug}:${isoWeek}`;
361
371
  const weeklyPayload = {
362
372
  title: `${proj.slug} weekly — ${m.tasks?.done || 0} shipped, $${(m.cost?.llm_usd || 0).toFixed(2)} spent`,
@@ -395,18 +405,22 @@ function startAlertCron() {
395
405
  const projects = listProjects();
396
406
  for (const proj of projects) {
397
407
  const dedupeKey = `digest.daily:${proj.slug}:${isoDay}`;
408
+ let tasks;
409
+ try {
410
+ tasks = await getTasksAsync(proj.path);
411
+ if (getReadDegradation(proj.path)) continue;
412
+ } catch { continue; } // Do not send or record a fabricated quiet day.
398
413
  // Pull last 2 days so "yesterday" is always in the window
399
- const cost = getCostHistory(proj.path, 2);
414
+ const cost = getCostHistory(proj.path, 2, { tasks });
400
415
  const yBucket = (cost.series || []).find(s => s.date === yesterday);
401
416
  const ySpend = yBucket ? yBucket.llm : 0;
402
417
  // Feature breakdown for yesterday
403
418
  const topFeatures = (cost.by_feature || []).slice(0, 3);
404
419
  // Inbox for blocked + gates
405
- const inbox = (() => { try { return getInbox(proj.path); } catch { return {}; } })();
420
+ const inbox = getInbox(proj.path, { tasks });
406
421
  const blocked = inbox.summary?.blocked ?? 0;
407
422
  const gates = inbox.summary?.gates ?? 0;
408
423
  // Tasks closed yesterday
409
- const tasks = (() => { try { return getTasks(proj.path, SWEEP); } catch { return []; } })();
410
424
  const doneYesterday = tasks.filter(t => {
411
425
  if (!t.closed_at) return false;
412
426
  return t.closed_at.slice(0, 10) === yesterday;
@@ -0,0 +1,70 @@
1
+ // A read's process-group lifetime must not depend on the HTTP event loop.
2
+ // Forked into a newly owned group; ordinary module imports do not start work.
3
+ import { fork, spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ const entry = fileURLToPath(import.meta.url);
6
+ export function spawnBdRead(binary, { readDeadlineMs = 20250, commandArgs, ...options }) {
7
+ const args = [binary, String(readDeadlineMs)];
8
+ if (commandArgs !== undefined) args.push(JSON.stringify(commandArgs));
9
+ return fork(entry, args, { ...options, silent: true });
10
+ }
11
+ if (process.argv[1] === entry) {
12
+ const grouped = process.platform !== 'win32';
13
+ let child;
14
+ function killOwnedGroup() {
15
+ // POSIX fork is detached: the worker PID is exclusively this read's PGID.
16
+ if (grouped) {
17
+ try { process.kill(-process.pid, 'SIGKILL'); } catch { process.exit(1); }
18
+ } else {
19
+ try { child?.kill('SIGKILL'); } catch { /* already gone */ }
20
+ process.exit(1);
21
+ }
22
+ }
23
+ process.on('disconnect', killOwnedGroup);
24
+ process.on('SIGTERM', () => {}); // keep the independent KILL deadline alive
25
+ process.stdout.on('error', killOwnedGroup);
26
+ process.stderr.on('error', killOwnedGroup);
27
+ // Independent upper bound even if the parent's event loop is stalled.
28
+ const budget = Number(process.argv[3]);
29
+ if (!Number.isFinite(budget) || budget < 1 || budget > 20250) process.exit(2);
30
+ setTimeout(killOwnedGroup, budget);
31
+ let completing = false;
32
+ function complete(code) {
33
+ if (completing) return;
34
+ completing = true;
35
+ // A successful leader can leave descendants with closed/ignored pipes.
36
+ // Flush the result over IPC before killing the whole owned group. Keep
37
+ // the independent deadline alive until cleanup, even if IPC stalls.
38
+ if (!process.connected) return killOwnedGroup();
39
+ // child 'close' means its pipes ended, not that this worker's output
40
+ // queues reached the parent. Flush before announcing success or KILL
41
+ // can truncate an otherwise valid snapshot under pipe backpressure.
42
+ try { process.stdout.write('', stdoutError => {
43
+ if (stdoutError) return killOwnedGroup();
44
+ process.stderr.write('', stderrError => {
45
+ if (stderrError || !process.connected) return killOwnedGroup();
46
+ try { process.send({ type: 'bd-read-result', code }, () => killOwnedGroup()); }
47
+ catch { killOwnedGroup(); }
48
+ });
49
+ }); }
50
+ catch { killOwnedGroup(); }
51
+ }
52
+ try {
53
+ const args = process.argv[4] === undefined
54
+ ? ['list', '--json', '--all', '--include-gates'] : JSON.parse(process.argv[4]);
55
+ if (!Array.isArray(args) || args.length > 8 || args.some(arg => typeof arg !== 'string' || arg.includes('\0'))
56
+ || JSON.stringify(args).length > 4096) throw Error('Invalid read command arguments');
57
+ child = spawn(process.argv[2], args, {
58
+ stdio: ['ignore', 'pipe', 'pipe']
59
+ });
60
+ child.stdout.pipe(process.stdout, { end: false });
61
+ child.stderr.pipe(process.stderr, { end: false });
62
+ child.on('error', () => complete(1));
63
+ child.on('close', code => {
64
+ // No background descendants are part of a completed read contract.
65
+ // Wait for inherited pipes too: exiting on leader 'exit' would remove
66
+ // the IPC watchdog while a descendant was still holding those pipes.
67
+ complete(Number.isInteger(code) ? code : 1);
68
+ });
69
+ } catch { complete(1); }
70
+ }
@@ -5,6 +5,7 @@ import { spawnSync, spawn } from 'child_process';
5
5
  import { readSafe } from './util.mjs';
6
6
  import { bdCache } from './state.mjs';
7
7
  import { log } from './log.mjs';
8
+ import { spawnBdRead } from './bd-read-worker.mjs';
8
9
 
9
10
  // ── Beads data ─────────────────────────────────────────────────────────────────
10
11
  // Cache bdList output per cwd for BD_CACHE_TTL_MS. Invalidated when the project's
@@ -39,13 +40,11 @@ import { log } from './log.mjs';
39
40
  const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
40
41
 
41
42
  /**
42
- * When a cwd was last told "your cache entry is wrong" — by an invalidate or
43
- * a stale-mark, whichever most recently ran. A background fetch that STARTED
44
- * before this timestamp answers a question nobody is asking anymore: see
45
- * `bdRefreshAsync`, which compares its own start time against this map before
46
- * committing its result, so a slow fetch racing a write always loses to it.
43
+ * Per-project invalidation generation. A background fetch may publish only
44
+ * when its generation is still current. Wall-clock timestamps cannot order
45
+ * an invalidate followed by a read within the same millisecond.
47
46
  */
48
- const lastInvalidatedAt = new Map();
47
+ const invalidationGeneration = new Map();
49
48
 
50
49
  /**
51
50
  * Drop a directory's entry so the next read is guaranteed fresh.
@@ -56,7 +55,7 @@ const lastInvalidatedAt = new Map();
56
55
  */
57
56
  function bdCacheInvalidate(cwd) {
58
57
  clearSelfTouch(cwd);
59
- lastInvalidatedAt.set(cwd, Date.now());
58
+ invalidationGeneration.set(cwd, (invalidationGeneration.get(cwd) || 0) + 1);
60
59
  bdCache.delete(cwd);
61
60
  }
62
61
 
@@ -73,7 +72,7 @@ function bdCacheInvalidate(cwd) {
73
72
  */
74
73
  function bdCacheStale(cwd) {
75
74
  clearSelfTouch(cwd);
76
- lastInvalidatedAt.set(cwd, Date.now());
75
+ invalidationGeneration.set(cwd, (invalidationGeneration.get(cwd) || 0) + 1);
77
76
  const cached = bdCache.get(cwd);
78
77
  if (cached) bdCache.set(cwd, { ...cached, ts: 0 });
79
78
  }
@@ -297,6 +296,17 @@ const EMPTY_TTL_MS = Number(process.env.GREAT_CTO_BD_EMPTY_TTL_MS || 5000);
297
296
  * Without this, ten requests arriving while one refresh runs start ten more.
298
297
  */
299
298
  const refreshing = new Set();
299
+ const refreshWaiters = new Map();
300
+ const refreshQueue = [];
301
+ let activeReads = 0;
302
+ const READ_TIMEOUT_MS = 20000;
303
+ const READ_MAX_BYTES = 16 * 1024 * 1024;
304
+ function drainRefreshQueue() {
305
+ while (activeReads < 4 && refreshQueue.length) {
306
+ activeReads++;
307
+ refreshQueue.shift()();
308
+ }
309
+ }
300
310
 
301
311
  /**
302
312
  * Refresh a directory's entry WITHOUT holding the event loop.
@@ -304,44 +314,108 @@ const refreshing = new Set();
304
314
  * `spawnSync` is what made this board unanswerable: `bd list` costs seconds and
305
315
  * blocks everything for the whole of it — /api/version, one readdirSync,
306
316
  * measured at 1-10 s because it was queued behind a task read. Warming at boot
307
- * moved the first stall out of sight; this removes the rest.
317
+ * moved the first stall out of sight; HTTP cold reads now await this path.
318
+ * Direct synchronous write broadcasts and background readers remain separate.
308
319
  *
309
- * `onDone`, if given, fires exactly once at every exit point (success,
310
- * failure, or "already refreshing"). Every existing caller omits it and stays
311
- * fire-and-forget — the ONE caller that wants to know when the fill finished
312
- * is the boot warm-up (see `warmTasksAsync`), which logs how long it took.
320
+ * `onDone`, if given, fires exactly once when the shared read finishes. A
321
+ * concurrent reader joins it rather than receiving a premature "skipped".
322
+ * Background callers omit it and stay fire-and-forget. Boot warm-up and
323
+ * interactive cold reads join the same completion through `warmTasksAsync`.
313
324
  */
314
- function bdRefreshAsync(cwd, onDone = () => {}) {
315
- if (refreshing.has(cwd)) { onDone({ ok: false, skipped: true }); return; }
325
+ function bdRefreshAsync(cwd, onDone = null) {
326
+ if (refreshing.has(cwd)) { if (onDone) refreshWaiters.get(cwd).push(onDone); return; }
316
327
  refreshing.add(cwd);
328
+ refreshWaiters.set(cwd, onDone ? [onDone] : []);
329
+ const due = performance.now() + READ_TIMEOUT_MS;
330
+ const run = () => {
331
+ clearTimeout(queuedDeadline);
332
+ startRefresh(cwd, Math.max(1, due - performance.now()));
333
+ };
334
+ const queuedDeadline = setTimeout(() => {
335
+ const index = refreshQueue.indexOf(run);
336
+ if (index < 0) return;
337
+ refreshQueue.splice(index, 1);
338
+ refreshing.delete(cwd);
339
+ bdFailures.set(cwd, 'bd read timed out waiting for a slot');
340
+ const waiters = refreshWaiters.get(cwd) || [];
341
+ refreshWaiters.delete(cwd);
342
+ for (const done of waiters) done({ ok: false });
343
+ }, READ_TIMEOUT_MS);
344
+ refreshQueue.push(run);
345
+ drainRefreshQueue();
346
+ }
347
+
348
+ function startRefresh(cwd, remainingMs) {
317
349
  const startedAt = Date.now();
350
+ const generation = invalidationGeneration.get(cwd) || 0;
318
351
  lastBdRunAt.set(cwd, startedAt);
319
- let out = '';
352
+ let out = '', outBytes = 0;
353
+ let child, deadline, killDeadline, finished = false, workerResult;
354
+ const grouped = process.platform !== 'win32';
355
+ function signalOwned(signal) {
356
+ if (!child?.pid) return;
357
+ try { if (grouped) process.kill(-child.pid, signal); else child.kill(signal); } catch { /* already exited */ }
358
+ }
359
+ function finish(result) {
360
+ if (finished) return;
361
+ finished = true;
362
+ clearTimeout(deadline); clearTimeout(killDeadline);
363
+ refreshing.delete(cwd);
364
+ const waiters = refreshWaiters.get(cwd) || [];
365
+ refreshWaiters.delete(cwd);
366
+ activeReads--;
367
+ for (const done of waiters) done(result);
368
+ drainRefreshQueue();
369
+ }
370
+ function stop(reason) {
371
+ if (finished || killDeadline) return;
372
+ bdFailures.set(cwd, reason);
373
+ signalOwned('SIGTERM');
374
+ // TERM alone can leave a read and inherited pipes alive indefinitely.
375
+ killDeadline = setTimeout(() => {
376
+ signalOwned('SIGKILL');
377
+ child.stdout?.destroy(); child.stderr?.destroy();
378
+ finish({ ok: false });
379
+ }, 250);
380
+ }
320
381
  try {
321
- // Bounded like every other bd call. Without a timeout a hung bd never fires
322
- // 'close', so `refreshing` stays held for this cwd forever and the cache
323
- // silently serves stale tasks with no error anywhere — the async twin of the
324
- // sync path's 500s. Node kills on timeout, which DOES fire 'close' with a
325
- // non-zero code, so the failure handling below reports it rather than the
326
- // board simply going quiet.
327
- const child = spawn(BD_BIN, ['list', '--json', '--all', '--include-gates'], { cwd, env: bdEnv(), timeout: 20000 });
328
- child.stdout?.on('data', (d) => { out += d; });
382
+ // At most four reads run concurrently. The fixed deadline escalates TERM
383
+ // to KILL for this newly owned process group; Node's TERM-only timeout did
384
+ // not release the slot when a child ignored it or inherited pipes stayed open.
385
+ child = spawnBdRead(BD_BIN, { cwd, env: bdEnv(), detached: grouped, readDeadlineMs: Math.ceil(remainingMs) + 250 });
386
+ child.on('message', message => {
387
+ if (message?.type === 'bd-read-result' && Number.isInteger(message.code)) workerResult = message.code;
388
+ });
389
+ deadline = setTimeout(() => stop('bd read timed out'), remainingMs);
390
+ child.stdout?.setEncoding('utf8');
391
+ child.stdout?.on('data', (d) => {
392
+ if (finished || killDeadline) return;
393
+ outBytes += Buffer.byteLength(d);
394
+ if (outBytes > READ_MAX_BYTES) { stop('bd output exceeded limit'); return; }
395
+ out += d;
396
+ });
397
+ // Drain stderr without retaining arbitrary private subprocess output.
398
+ child.stderr?.resume();
329
399
  child.on('error', (e) => {
330
- refreshing.delete(cwd);
331
400
  bdFailures.set(cwd, `bd could not be run: ${e?.message || e}`.slice(0, 300));
332
- onDone({ ok: false });
401
+ finish({ ok: false });
333
402
  });
334
403
  child.on('close', (code) => {
335
- refreshing.delete(cwd);
404
+ if (finished) return;
405
+ if (killDeadline) { signalOwned('SIGKILL'); finish({ ok: false }); return; }
406
+ // The worker reports its leader result, then terminates its entire
407
+ // owned group, including itself. A bare signal without that result
408
+ // remains a failed read, never a fabricated successful empty list.
409
+ code = workerResult ?? code;
336
410
  lastBdRunAt.set(cwd, Date.now());
337
- if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); onDone({ ok: false }); return; }
411
+ if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); finish({ ok: false }); return; }
338
412
  try {
339
413
  const parsed = JSON.parse(out || '[]');
340
414
  // Same guard as the sync path: bd 0.6x can answer 0 with a JSON object,
341
415
  // and a non-array rendered as no tasks is the silent zero again.
342
416
  if (!Array.isArray(parsed)) {
343
417
  bdFailures.set(cwd, String(parsed?.error || 'bd returned something that is not a task list').slice(0, 300));
344
- onDone({ ok: false });
418
+ finish({ ok: false });
345
419
  return;
346
420
  }
347
421
  // This fetch answers "what was true at `startedAt`?" — if anyone has
@@ -353,24 +427,23 @@ function bdRefreshAsync(cwd, onDone = () => {}) {
353
427
  // `tests/pipeline-e2e.test.mjs`'s gate-approval assertions flake once
354
428
  // this function started running from a COLD cache (the boot warm-up)
355
429
  // instead of only ever refreshing an already-populated one.
356
- const supersededByInvalidation = (lastInvalidatedAt.get(cwd) || 0) >= startedAt;
430
+ const supersededByInvalidation = (invalidationGeneration.get(cwd) || 0) !== generation;
357
431
  const supersededByFresherEntry = (bdCache.get(cwd)?.ts || 0) >= startedAt;
358
432
  if (supersededByInvalidation || supersededByFresherEntry) {
359
- onDone({ ok: true, count: parsed.length, discarded: true });
433
+ finish({ ok: true, count: parsed.length, discarded: true });
360
434
  return;
361
435
  }
362
436
  bdFailures.delete(cwd);
363
437
  bdCache.set(cwd, { ts: Date.now(), data: parsed });
364
- onDone({ ok: true, count: parsed.length });
438
+ finish({ ok: true, count: parsed.length });
365
439
  } catch (e) {
366
440
  bdFailures.set(cwd, `bd output could not be parsed: ${e?.message || e}`.slice(0, 300));
367
- onDone({ ok: false });
441
+ finish({ ok: false });
368
442
  }
369
443
  });
370
444
  } catch (e) {
371
- refreshing.delete(cwd);
372
445
  bdFailures.set(cwd, `bd could not be spawned: ${e?.message || e}`.slice(0, 300));
373
- onDone({ ok: false });
446
+ finish({ ok: false });
374
447
  }
375
448
  }
376
449
 
@@ -410,6 +483,34 @@ function warmTasksAsync(cwd) {
410
483
  });
411
484
  }
412
485
 
486
+ /** Wait for an actual cold snapshot without blocking Node or inventing []. */
487
+ async function getTasksAsync(cwd = process.cwd()) {
488
+ if (!bdCache.has(cwd)) {
489
+ const result = await warmTasksAsync(cwd);
490
+ if (!bdCache.has(cwd)) {
491
+ // tasks.md-only projects remain supported, but never hide a failed
492
+ // initialized Beads store or a snapshot invalidated during this read.
493
+ if (!result.ok && checkBeadsAvailable(cwd)) {
494
+ bdCache.set(cwd, { ts: Date.now(), data: [] });
495
+ return getTasks(cwd);
496
+ }
497
+ throw new Error(bdFailureFor(cwd) || 'Task snapshot changed while being read; retry');
498
+ }
499
+ }
500
+ return getTasks(cwd);
501
+ }
502
+
503
+ /** Other-project badges may be incomplete, but must never block HTTP. */
504
+ function getTasksCached(cwd = process.cwd()) {
505
+ if (!bdCache.has(cwd)) {
506
+ bdRefreshAsync(cwd);
507
+ throw new Error('Task snapshot is loading');
508
+ }
509
+ const tasks = getTasks(cwd);
510
+ if (getReadDegradation(cwd)) throw new Error('Task snapshot is unavailable');
511
+ return tasks;
512
+ }
513
+
413
514
  function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
414
515
  const maxAge = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : BD_CACHE_TTL_MS;
415
516
  const cached = bdCache.get(cwd);
@@ -864,6 +965,8 @@ export {
864
965
  getReadDegradation,
865
966
  setTaskStatusInTasksMd,
866
967
  getTasks,
968
+ getTasksAsync,
969
+ getTasksCached,
867
970
  mapStatus,
868
971
  detectAgent,
869
972
  };
@@ -3,7 +3,7 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { GREAT_CTO_DIR } from './config.mjs';
5
5
  import { readFileSafe } from './util.mjs';
6
- import { getTasks } from './beads.mjs';
6
+ import { getTasks, getTasksAsync, getTasksCached } from './beads.mjs';
7
7
  import { readVerdicts, readSecStats } from './verdicts.mjs';
8
8
  import { datePlans } from './plan-date.mjs';
9
9
 
@@ -73,7 +73,7 @@ function getMemory(cwd = process.cwd()) {
73
73
  }
74
74
 
75
75
  // ── Pipeline state ────────────────────────────────────────────────────────────
76
- function getPipeline(cwd = process.cwd()) {
76
+ function getPipeline(cwd = process.cwd(), { tasks = getTasks(cwd) } = {}) {
77
77
  const stages = ['product-owner', 'architect', 'pm', 'senior-dev', 'reviewers', 'qa-engineer', 'security-officer', 'devops', 'l3-support'];
78
78
  const verdicts = readVerdicts(cwd);
79
79
  const now = Date.now();
@@ -87,7 +87,6 @@ function getPipeline(cwd = process.cwd()) {
87
87
  }
88
88
 
89
89
  // Tasks in_progress give us "active" agents
90
- const tasks = getTasks(cwd);
91
90
  const activeAgents = new Set(
92
91
  tasks.filter(t => t.status === 'in_progress').map(t => (t.agent || '').toLowerCase()).filter(Boolean)
93
92
  );
@@ -179,7 +178,7 @@ function getPipeline(cwd = process.cwd()) {
179
178
  }
180
179
 
181
180
  // ── Cost history (daily LLM burn) ────────────────────────────────────────────
182
- function getCostHistory(cwd = process.cwd(), days = 30) {
181
+ function getCostHistory(cwd = process.cwd(), days = 30, { tasks: suppliedTasks } = {}) {
183
182
  // Build a map<dateISO, { llm, human, plans, verdictCost }>.
184
183
  // Inclusive window: `days=30` means [today-30 ... today] = 31 buckets.
185
184
  // Previous behaviour (days buckets only) created a one-day gap on the
@@ -315,7 +314,7 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
315
314
  const LLM_RATE_PER_HR = parseFloat(process.env.GREATCTO_LLM_RATE_PER_HR || '0.30');
316
315
  const DEFAULT_TASK_MIN = 30;
317
316
  try {
318
- const tasks = getTasks(cwd);
317
+ const tasks = suppliedTasks ?? getTasks(cwd);
319
318
  for (const t of tasks) {
320
319
  if (!t.closed_at) continue;
321
320
  if (!t.agent) continue;
@@ -401,8 +400,7 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
401
400
  }
402
401
 
403
402
  // ── Inbox: what needs the user's decision right now ──────────────────────────
404
- function getInbox(cwd = process.cwd()) {
405
- const tasks = getTasks(cwd);
403
+ function getInbox(cwd = process.cwd(), { tasks = getTasks(cwd) } = {}) {
406
404
  // Use raw_status here: mapStatus() rewrites status to 'gate' for any task with the
407
405
  // 'gate' label, regardless of bd-native state. Filtering on the mapped value would
408
406
  // leave closed/blocked gates in the inbox forever.
@@ -502,7 +500,7 @@ function getInbox(cwd = process.cwd()) {
502
500
  * `readInbox` is injectable so the walk can be tested with stub projects
503
501
  * without a beads store behind each one.
504
502
  */
505
- function inboxElsewhere(projects, currentPath, { readInbox = getInbox } = {}) {
503
+ function inboxElsewhere(projects, currentPath, { readInbox = getInboxCached } = {}) {
506
504
  const same = (a, b) => {
507
505
  if (!a || !b) return false;
508
506
  try { return fs.realpathSync(a) === fs.realpathSync(b); } catch { return path.resolve(a) === path.resolve(b); }
@@ -521,4 +519,6 @@ function inboxElsewhere(projects, currentPath, { readInbox = getInbox } = {}) {
521
519
  return out;
522
520
  }
523
521
 
524
- export { getMemory, getPipeline, getCostHistory, getInbox, inboxElsewhere };
522
+ async function getInboxAsync(cwd) { return getInbox(cwd, { tasks: await getTasksAsync(cwd) }); }
523
+ function getInboxCached(cwd) { return getInbox(cwd, { tasks: getTasksCached(cwd) }); }
524
+ export { getMemory, getPipeline, getCostHistory, getInbox, getInboxAsync, getInboxCached, inboxElsewhere };
@@ -19,6 +19,7 @@ import fs from 'fs';
19
19
  import path from 'path';
20
20
  import { randomUUID } from 'crypto';
21
21
  import { treeReceipt, receiptHash, compareReceipts } from '../../../scripts/lib/receipt.mjs';
22
+ import { readBindingReceipt } from './receipt-read.mjs';
22
23
 
23
24
  export const TOKENS_FILE = 'gate-tokens.json';
24
25
  export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
@@ -36,6 +37,30 @@ export function tokenTtlMs(env = process.env) {
36
37
  }
37
38
 
38
39
  const storePath = (cwd) => path.join(cwd, '.great_cto', TOKENS_FILE);
40
+ // One board owns its HTTP listener. Fence awaited projections against that
41
+ // board's decision lifecycle; this is not a distributed multi-board lock.
42
+ const decisions = new Map();
43
+ function decisionState(cwd) {
44
+ let key;
45
+ try { key = fs.realpathSync(cwd); } catch { return null; }
46
+ if (!decisions.has(key)) decisions.set(key, { generation: randomUUID(), active: false });
47
+ return decisions.get(key);
48
+ }
49
+ export function approvalSnapshot(cwd) {
50
+ const state = decisionState(cwd);
51
+ return state ? { generation: state.generation, active: state.active } : { generation: null, active: true };
52
+ }
53
+ function snapshotFresh(cwd, snapshot) {
54
+ const state = decisionState(cwd);
55
+ return state && snapshot && !snapshot.active && !state.active && snapshot.generation === state.generation;
56
+ }
57
+ export function beginGateDecision(cwd) {
58
+ const state = decisionState(cwd);
59
+ if (!state || state.active) return null;
60
+ state.active = true; state.generation = randomUUID();
61
+ let ended = false;
62
+ return () => { if (!ended) { ended = true; state.active = false; state.generation = randomUUID(); } };
63
+ }
39
64
 
40
65
  /** @returns {{state:'none'|'ok'|'unreadable', tokens:object, why?:string}} */
41
66
  export function readTokens(cwd) {
@@ -77,7 +102,7 @@ export function bindingReceipt(cwd) {
77
102
  * @param {{id:string, gate?:string, guarded?:boolean}[]} gates
78
103
  * @returns {{state:'ok'|'unreadable', tokens:Record<string,string>, why?:string}}
79
104
  */
80
- export function issueTokens(cwd, gates, { now = Date.now(), env = process.env } = {}) {
105
+ export function issueTokens(cwd, gates, { now = Date.now(), env = process.env, readReceipt = bindingReceipt } = {}) {
81
106
  const store = readTokens(cwd);
82
107
  if (store.state === 'unreadable') return { state: 'unreadable', tokens: {}, why: store.why };
83
108
  const ttl = tokenTtlMs(env);
@@ -90,7 +115,7 @@ export function issueTokens(cwd, gates, { now = Date.now(), env = process.env }
90
115
  else toMint.push(g);
91
116
  }
92
117
  if (toMint.length) {
93
- const receipt = bindingReceipt(cwd);
118
+ const receipt = readReceipt(cwd);
94
119
  const hash = receiptHash(receipt);
95
120
  for (const g of toMint) {
96
121
  next[g.id] = {
@@ -104,6 +129,29 @@ export function issueTokens(cwd, gates, { now = Date.now(), env = process.env }
104
129
  return { state: 'ok', tokens: Object.fromEntries(Object.entries(next).map(([id, e]) => [id, e.token])) };
105
130
  }
106
131
 
132
+ export async function issueTokensAsync(cwd, gates, { env = process.env, readReceipt = readBindingReceipt, snapshot = approvalSnapshot(cwd) } = {}) {
133
+ const stale = () => ({ state: 'unreadable', tokens: {}, why: 'Gate decisions changed during this snapshot; reload the inbox' });
134
+ if (!snapshotFresh(cwd, snapshot)) return stale();
135
+ const store = readTokens(cwd), now = Date.now(), ttl = tokenTtlMs(env);
136
+ if (store.state === 'unreadable') return { state: 'unreadable', tokens: {}, why: store.why };
137
+ const needsReceipt = (gates || []).some(g => g?.id &&
138
+ (!store.tokens[g.id] || typeof store.tokens[g.id].token !== 'string' || !Number.isFinite(store.tokens[g.id].firstSeenAt) || now - store.tokens[g.id].firstSeenAt > ttl));
139
+ try {
140
+ const receipt = needsReceipt ? await readReceipt(cwd) : undefined;
141
+ if (!snapshotFresh(cwd, snapshot)) return stale();
142
+ const current = readTokens(cwd);
143
+ // Also refuse disappearance/replacement of an existing token by another
144
+ // store writer. Concurrent mint-only consumers remain safe to coalesce.
145
+ if (Object.entries(store.tokens).some(([id, entry]) => current.tokens[id]?.token !== entry?.token)) return stale();
146
+ // Reread the store after await: another consumer may already have minted
147
+ // these tokens, or corruption may have appeared. Never replace that state.
148
+ return issueTokens(cwd, gates, { now: Date.now(), env, readReceipt: () => {
149
+ if (!needsReceipt) throw Error('Receipt no longer available');
150
+ return receipt;
151
+ } });
152
+ } catch { return { state: 'unreadable', tokens: {}, why: 'Approval receipt is unavailable; retry' }; }
153
+ }
154
+
107
155
  /**
108
156
  * Is `token` the live token for gate `id`?
109
157
  * @returns {{ok:true, entry:object} | {ok:false, status:number, outcome:string, why:string}}
@@ -143,6 +191,9 @@ export function checkBinding(cwd, entry) {
143
191
 
144
192
  /** Single use: drop the gate's token once it has been decided. */
145
193
  export function consumeToken(cwd, id) {
194
+ const state = decisionState(cwd);
195
+ if (!state) return;
196
+ state.generation = randomUUID();
146
197
  const store = readTokens(cwd);
147
198
  if (store.state !== 'ok' || !(id in store.tokens)) return;
148
199
  const next = { ...store.tokens };
@@ -43,11 +43,10 @@ function acceptanceMetrics(verdicts = [], windowCostUsd = null) {
43
43
  }
44
44
 
45
45
  // ── Metrics ────────────────────────────────────────────────────────────────────
46
- function getMetrics(cwd = process.cwd(), days = 30) {
46
+ function getMetrics(cwd = process.cwd(), days = 30, { tasks = getTasks(cwd) } = {}) {
47
47
  // `days` controls the window for cost/agents_cost and for "shipped in window".
48
48
  // Tasks shipped within the window are returned in `tasks.done_in_window`.
49
49
  // Full lifetime `tasks.done` is still returned for backwards compatibility.
50
- const tasks = getTasks(cwd);
51
50
  // BH-28: resolved gates (approve→closed, reject→blocked) get mapped to
52
51
  // status='done' by mapStatus(). Those are governance decisions, not shipped
53
52
  // features — counting them inflates velocity / tasks-shipped on the report.
@@ -0,0 +1,37 @@
1
+ // Invoked only by the independent read supervisor, never by an HTTP argument.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { treeReceipt, latestApproval, compareReceipts, mergeBase } from '../../../scripts/lib/receipt.mjs';
6
+ import { secondOpinionForTree } from '../../../scripts/lib/second-opinion-for-tree.mjs';
7
+ const entry = fileURLToPath(import.meta.url);
8
+ export const receiptReadEntry = entry;
9
+ if (process.argv[1] === entry) {
10
+ try {
11
+ const mode = process.argv[2] || 'binding';
12
+ if (!['binding', 'approval', 'second-opinion'].includes(mode) || process.argv.length > 3) throw Error('Invalid evidence operation');
13
+ let receipt;
14
+ if (mode === 'second-opinion') {
15
+ receipt = secondOpinionForTree(process.cwd());
16
+ } else if (mode === 'approval') {
17
+ const approval = latestApproval(process.cwd());
18
+ if (!approval?.receipt) {
19
+ receipt = { state: 'no-receipt', why: 'no approving verdict carries a receipt yet', agent: approval?.agent || null, ts: approval?.ts || null };
20
+ } else {
21
+ const current = treeReceipt(process.cwd(), { base: approval.receipt.base || mergeBase(process.cwd()), hashFilesInProcess: true });
22
+ receipt = { ...compareReceipts(approval.receipt, current, { cwd: process.cwd() }), agent: approval.agent, ts: approval.ts };
23
+ }
24
+ } else {
25
+ let dir = process.cwd(), gitMetadata = false;
26
+ for (;;) {
27
+ try { fs.lstatSync(path.join(dir, '.git')); gitMetadata = true; break; } catch (e) { if (e.code !== 'ENOENT') throw e; }
28
+ const parent = path.dirname(dir); if (parent === dir) break; dir = parent;
29
+ }
30
+ receipt = treeReceipt(process.cwd(), { exclude: ['.great_cto', '.beads'], hashFilesInProcess: true });
31
+ // Preserve explicit non-Git binding, but never mint an unbound token when
32
+ // a real repository's evidence read failed.
33
+ if (!receipt && gitMetadata) throw Error('Repository receipt unavailable');
34
+ }
35
+ process.stdout.write(JSON.stringify({ receipt }), error => process.exit(error ? 1 : 0));
36
+ } catch { process.exit(1); }
37
+ }
@@ -0,0 +1,70 @@
1
+ import path from 'node:path';
2
+ import { spawnBdRead } from './bd-read-worker.mjs';
3
+ import { receiptReadEntry } from './receipt-read-worker.mjs';
4
+ const pending = new Map();
5
+ const MAX_ACTIVE = 2, MAX_BYTES = 16 * 1024 * 1024;
6
+ export function readBindingReceipt(cwd, { timeoutMs = 20000 } = {}) {
7
+ return readEvidence(cwd, 'binding', timeoutMs);
8
+ }
9
+ export function readApprovalReceipt(cwd, { timeoutMs = 20000 } = {}) {
10
+ return readEvidence(cwd, 'approval', timeoutMs);
11
+ }
12
+ export function readSecondOpinion(cwd, { timeoutMs = 20000 } = {}) {
13
+ return readEvidence(cwd, 'second-opinion', timeoutMs);
14
+ }
15
+ function readEvidence(cwd, mode, timeoutMs) {
16
+ cwd = path.resolve(cwd);
17
+ const key = JSON.stringify([cwd, mode]);
18
+ if (pending.has(key)) return pending.get(key);
19
+ if (pending.size >= MAX_ACTIVE) return Promise.reject(Error('Receipt reader busy; retry'));
20
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 20000) return Promise.reject(Error('Invalid receipt deadline'));
21
+ const operation = new Promise((resolve, reject) => {
22
+ let child, deadline, escalation, settled = false, resultCode, out = '', bytes = 0;
23
+ const grouped = process.platform !== 'win32';
24
+ const signal = s => { try { if (grouped) process.kill(-child.pid, s); else child.kill(s); } catch {} };
25
+ const finish = (error, value) => {
26
+ if (settled) return; settled = true; clearTimeout(deadline); clearTimeout(escalation);
27
+ if (error) reject(error); else resolve(value);
28
+ };
29
+ const stop = message => {
30
+ if (settled || escalation) return;
31
+ signal('SIGTERM');
32
+ escalation = setTimeout(() => {
33
+ signal('SIGKILL'); child.stdout?.destroy(); child.stderr?.destroy(); finish(Error(message));
34
+ }, 250);
35
+ };
36
+ try {
37
+ child = spawnBdRead(process.execPath, { cwd, detached: grouped, commandArgs: [receiptReadEntry, mode], readDeadlineMs: timeoutMs + 250 });
38
+ deadline = setTimeout(() => stop('Receipt read timed out'), timeoutMs);
39
+ child.on('message', m => { if (m?.type === 'bd-read-result' && Number.isInteger(m.code)) resultCode = m.code; });
40
+ child.stdout.setEncoding('utf8');
41
+ child.stdout.on('data', chunk => {
42
+ if (settled || escalation) return;
43
+ bytes += Buffer.byteLength(chunk);
44
+ if (bytes > MAX_BYTES) stop('Receipt output exceeded limit'); else out += chunk;
45
+ });
46
+ child.stderr.resume();
47
+ child.on('error', () => finish(Error('Receipt worker unavailable')));
48
+ child.on('close', code => {
49
+ if (settled) return;
50
+ if (escalation) { signal('SIGKILL'); finish(Error('Receipt read unavailable')); return; }
51
+ if ((resultCode ?? code) !== 0) { finish(Error('Receipt worker failed')); return; }
52
+ try {
53
+ const value = JSON.parse(out);
54
+ if (!value || !Object.hasOwn(value, 'receipt')) throw Error();
55
+ const evidence = value.receipt;
56
+ if (mode === 'binding') {
57
+ if (evidence !== null && (!evidence?.head || !evidence.files)) throw Error();
58
+ } else {
59
+ const states = mode === 'approval' ? ['no-receipt', 'unreadable', 'differs', 'extended', 'matches'] : ['not-run', 'unmeasured', 'unreadable', 'ok'];
60
+ if (!states.includes(evidence?.state)) throw Error();
61
+ }
62
+ finish(null, evidence);
63
+ } catch { finish(Error('Receipt worker returned invalid evidence')); }
64
+ });
65
+ } catch { finish(Error('Receipt worker unavailable')); }
66
+ });
67
+ pending.set(key, operation);
68
+ operation.then(() => pending.delete(key), () => pending.delete(key));
69
+ return operation;
70
+ }
@@ -27,12 +27,12 @@ import { status as routerKeyStatus, writeKey as writeRouterKey, verifyKey as ver
27
27
  let routerKeyCheck = null;
28
28
  import { broadcastTasks } from './sse.mjs';
29
29
  import { saveNotifHistory } from './notifications.mjs';
30
- import { getMemory, getPipeline, getCostHistory, getInbox, inboxElsewhere } from './data-readers.mjs';
30
+ import { getMemory, getPipeline, getCostHistory, getInbox, getInboxAsync, inboxElsewhere } from './data-readers.mjs';
31
31
  import { log } from './log.mjs';
32
- import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
32
+ import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, getTasksAsync, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
33
33
  import { getMetrics } from './metrics.mjs';
34
34
  import { agentActivity, agentActivitySince } from './agent-activity.mjs';
35
- import { issueTokens, checkToken, checkBinding, consumeToken } from './gate-tokens.mjs';
35
+ import { issueTokensAsync, checkToken, checkBinding, consumeToken, approvalSnapshot, beginGateDecision } from './gate-tokens.mjs';
36
36
  import { readTurns, turnPatch } from '../../../scripts/lib/turn-snapshot.mjs';
37
37
  import { appendEvent } from '../../../scripts/lib/agent-events.mjs';
38
38
 
@@ -63,6 +63,7 @@ import { upsertCapability, capabilitiesFromProjectMd } from '../../../scripts/li
63
63
  // Moved to scripts/lib so the cross-review Stop hook can ask the same question
64
64
  // without importing a server. Re-exported here: this module's callers are unchanged.
65
65
  import { secondOpinionForTree } from '../../../scripts/lib/second-opinion-for-tree.mjs';
66
+ import { readApprovalReceipt, readSecondOpinion } from './receipt-read.mjs';
66
67
  import { getAgentsFleet, getAgentProfile, retireAgent, restoreAgent, appendDecisionLog, readDecisionsLog } from './fleet.mjs';
67
68
  import { getResume, getShareState, toggleShare } from './share.mjs';
68
69
  import { listSessions, readSession, editedFiles, searchSessions } from './transcripts.mjs';
@@ -124,6 +125,32 @@ async function dispatch(req, res, url, cwd) {
124
125
  const scopedProject = ['/api/skills', '/api/usage', '/api/outcomes'].includes(pathname)
125
126
  ? scopedReadProject(cwd, serverCwd, requestedProject) : null;
126
127
 
128
+ // Existing synchronous projections may consume a completed snapshot, but
129
+ // HTTP cold reads must await async I/O instead of freezing unrelated clients.
130
+ const taskRead = ['/api/work', '/api/sse', '/api/tasks', '/api/metrics',
131
+ '/api/memory', '/api/pipeline', '/api/heartbeat', '/api/share', '/api/cost', '/api/resume'].includes(pathname)
132
+ || /^\/api\/tasks\/[^/]+\/history$/.test(pathname);
133
+ const unknownWork = pathname === '/api/work' && requestedProject
134
+ && resolveProjectInfo(requestedProject).resolved === 'fallback';
135
+ let sseClosed = false;
136
+ if (pathname === '/api/sse') {
137
+ // Install lifecycle ownership before the cold snapshot await. Request
138
+ // 'close' also occurs for normally completed GET bodies; response close
139
+ // is the actual stream lifetime signal.
140
+ res.once('close', () => { sseClosed = true; sseClients.delete(res); });
141
+ }
142
+ const closedSse = () => pathname === '/api/sse'
143
+ && (sseClosed || req.aborted || res.destroyed || res.writableEnded);
144
+ if (req.method === 'GET' && taskRead && !unknownWork) {
145
+ try { await getTasksAsync(cwd); }
146
+ catch {
147
+ if (closedSse()) return true;
148
+ res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
149
+ res.end(JSON.stringify({ error: 'Task snapshot is unavailable; retry', state: 'unreadable' }));
150
+ return true;
151
+ }
152
+ }
153
+
127
154
  if (pathname === '/api/work/publication') {
128
155
  const respond = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
129
156
  if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') { respond(404, { error: 'Unknown project' }); return true; }
@@ -222,6 +249,7 @@ async function dispatch(req, res, url, cwd) {
222
249
 
223
250
  // SSE
224
251
  if (pathname === '/api/sse') {
252
+ if (closedSse()) return true;
225
253
  res.writeHead(200, {
226
254
  'Content-Type': 'text/event-stream',
227
255
  'Cache-Control': 'no-cache',
@@ -229,7 +257,11 @@ async function dispatch(req, res, url, cwd) {
229
257
  });
230
258
  res._gctoCwd = cwd; // remember which project this client wants
231
259
  sseClients.add(res);
232
- res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`);
260
+ if (getReadDegradation(cwd)) {
261
+ res.write(`event: task-read-state\ndata: ${JSON.stringify({ ok: false, state: 'unreadable' })}\n\n`);
262
+ } else {
263
+ res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`);
264
+ }
233
265
  // ADR-021: the agent activity strip starts from a snapshot, then follows pushes.
234
266
  // Phase 2: a client that hands back the cursor of the last frame it saw — the
235
267
  // Last-Event-ID header, or ?since= from a page that had to recreate its
@@ -243,7 +275,6 @@ async function dispatch(req, res, url, cwd) {
243
275
  }
244
276
  res._gctoAgentCursor = a.cursor;
245
277
  res.write(`${a.cursor ? `id: ${a.cursor}\n` : ''}event: agent\ndata: ${JSON.stringify(a)}\n\n`);
246
- req.on('close', () => sseClients.delete(res));
247
278
  return true;
248
279
  }
249
280
 
@@ -713,12 +744,18 @@ async function dispatch(req, res, url, cwd) {
713
744
  // Named outside the serialised block so the wake below can say WHICH gate a
714
745
  // human approved; the lookup itself belongs inside the lock.
715
746
  let gateTitle = id;
747
+ const endDecision = beginGateDecision(gateCwd);
748
+ if (!endDecision) {
749
+ refuse({ status: 409, outcome: 'refused-busy', why: 'A gate decision is in progress for this project; retry' });
750
+ return;
751
+ }
752
+ try {
716
753
 
717
754
  // BH-16 fix: serialise gate writes through bd-write queue.
718
755
  // Without this, concurrent approve+reject on the same gate produced
719
756
  // TWO appendDecisionLog entries (one wrong) — log says approved AND
720
757
  // rejected. bdWriteSerialised guarantees one-at-a-time semantics.
721
- const result = await bdWriteSerialised(() => {
758
+ const result = await bdWriteSerialised(async () => {
722
759
  const status = action === 'approve' ? 'closed' : 'blocked';
723
760
  let via = 'beads';
724
761
  // Try beads first (unless there's no store at all); on any bd failure
@@ -761,7 +798,7 @@ async function dispatch(req, res, url, cwd) {
761
798
  const projectSlug = parsed.project && !/^[\/~]/.test(parsed.project) ? parsed.project : path.basename(gateCwd);
762
799
  let title = id;
763
800
  try {
764
- const gateTask = getTasks(gateCwd).find((t) => t.id === id);
801
+ const gateTask = (await getTasksAsync(gateCwd)).find((t) => t.id === id);
765
802
  if (gateTask?.title) title = gateTask.title;
766
803
  } catch { /* the id is a documented fallback; the record still gets written */ }
767
804
  gateTitle = title;
@@ -841,13 +878,21 @@ async function dispatch(req, res, url, cwd) {
841
878
  .catch(e => log.warn(`report: republish after gate failed: ${e.message}`));
842
879
  }
843
880
  }
881
+ } finally { endDecision(); }
844
882
  });
845
883
  return true;
846
884
  }
847
885
 
848
886
  // Inbox — what needs your attention right now
849
887
  if (pathname === '/api/inbox') {
850
- const inbox = getInbox(cwd);
888
+ const snapshot = approvalSnapshot(cwd);
889
+ let inbox;
890
+ try { inbox = await getInboxAsync(cwd); }
891
+ catch {
892
+ res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
893
+ res.end(JSON.stringify({ error: 'Task snapshot is unavailable; retry', state: 'unreadable' }));
894
+ return true;
895
+ }
851
896
  // BRD-R3: the Decisions row shows both reviewers. The second opinion is a
852
897
  // fact about the TREE, not about a gate — every pending gate on this tree
853
898
  // shares it — so it is resolved once: the newest cross-review line whose
@@ -855,23 +900,25 @@ async function dispatch(req, res, url, cwd) {
855
900
  // verdict: `not-run` (capability none / undeclared / unavailable),
856
901
  // `unmeasured` (declared, no line for this sha), `unreadable` (only
857
902
  // pre-join-key lines exist), `ok` (a paired verdict).
858
- const second_opinion = secondOpinionForTree(cwd);
903
+ let second_opinion;
904
+ try { second_opinion = await readSecondOpinion(cwd); }
905
+ catch { second_opinion = { state: 'unreadable', why: 'Second opinion evidence is unavailable; retry', verdict: null }; }
859
906
  // What is waiting on the person in their OTHER projects. The headline and
860
907
  // the badge are about the person, and the person is not scoped to `cwd`.
861
908
  // If the registry itself cannot be walked, say so — `unreadable` is not
862
909
  // `{p0: 0}`, and the page must not print "nothing elsewhere" from it.
863
910
  let elsewhere;
864
- try { elsewhere = inboxElsewhere(listProjects(), cwd, { readInbox: getInbox }); }
911
+ try { elsewhere = inboxElsewhere(listProjects(), cwd); }
865
912
  catch (e) { elsewhere = { state: 'unreadable', why: String(e?.message || e) }; }
866
913
  // ADR-024 §1: each pending gate carries the token an approval must present,
867
914
  // bound to the project as it is now. The typed name stored with it is the one
868
915
  // the page asks for; a store that cannot be read issues nothing and says so.
869
916
  const gates = inbox.pending_gates || [];
870
- const issued = issueTokens(cwd, gates.map((g) => ({
917
+ const issued = await issueTokensAsync(cwd, gates.map((g) => ({
871
918
  id: g.id,
872
919
  gate: g.reversibility?.gate ? `gate:${g.reversibility.gate}` : g.id,
873
920
  guarded: ['expensive', 'unclassified'].includes(g.reversibility?.state),
874
- })));
921
+ })), { snapshot });
875
922
  const pending_gates = gates.map((g) => ({ ...g, token: issued.tokens[g.id] || null }));
876
923
  const approval_tokens = { state: issued.state, ...(issued.why ? { why: issued.why } : {}) };
877
924
  res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
@@ -890,15 +937,7 @@ async function dispatch(req, res, url, cwd) {
890
937
  const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
891
938
  let out;
892
939
  try {
893
- const { latestApproval, treeReceipt, compareReceipts, mergeBase } = await import('../../../scripts/lib/receipt.mjs');
894
- const approval = latestApproval(c);
895
- if (!approval?.receipt) {
896
- out = { state: 'no-receipt', why: 'no approving verdict carries a receipt yet', agent: approval?.agent || null, ts: approval?.ts || null };
897
- } else {
898
- const current = treeReceipt(c, { base: approval.receipt.base || mergeBase(c) });
899
- const cmp = compareReceipts(approval.receipt, current, { cwd: c });
900
- out = { ...cmp, agent: approval.agent, ts: approval.ts };
901
- }
940
+ out = await readApprovalReceipt(c);
902
941
  } catch (e) {
903
942
  // Never "matches" on an error. Not knowing whether the approval still
904
943
  // holds is its own state, and calling it a match would put the strongest
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { GREAT_CTO_DIR, PUBLIC, SHARE_ENDPOINT } from './config.mjs';
4
4
  import { readProjectMd } from './projects.mjs';
5
5
  import { getMetrics } from './metrics.mjs';
6
- import { getTasks } from './beads.mjs';
6
+ import { getTasks, getTasksAsync, getReadDegradation } from './beads.mjs';
7
7
  import { readVerdicts } from './verdicts.mjs';
8
8
  import { readDecisionsLog } from './fleet.mjs';
9
9
 
@@ -87,8 +87,10 @@ async function toggleShare(enable, cwd = process.cwd(), force = false) {
87
87
  // Generate and publish
88
88
  // Share report is a marketing artifact — show LIFETIME numbers, not a
89
89
  // rolling window. 365 days × 100 = effectively-lifetime cap for any project.
90
- const html = generateShareHTML(getTasks(cwd), getMetrics(cwd, 36500), cwd);
91
90
  try {
91
+ const tasks = await getTasksAsync(cwd);
92
+ if (getReadDegradation(cwd)) return { error: 'Task snapshot is unavailable; report not published' };
93
+ const html = generateShareHTML(tasks, getMetrics(cwd, 36500, { tasks }), cwd);
92
94
  const result = await publishReport(html);
93
95
  const newState = { enabled: true, url: result.url, hash: result.hash, published_at: new Date().toISOString() };
94
96
  saveShareState(newState, cwd);
@@ -1,5 +1,6 @@
1
1
  import { sseClients } from './state.mjs';
2
- import { getTasks } from './beads.mjs';
2
+ import { getTasksAsync, getReadDegradation, warmTasksAsync, checkBeadsAvailable } from './beads.mjs';
3
+ import { getPipeline, getInbox } from './data-readers.mjs';
3
4
 
4
5
  function broadcast(event, data) {
5
6
  const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -8,13 +9,41 @@ function broadcast(event, data) {
8
9
  }
9
10
  }
10
11
 
11
- function broadcastTasks(cwd) {
12
- const msg = `event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`;
13
- for (const res of sseClients) {
14
- if (res._gctoCwd === cwd) {
12
+ async function broadcastTasks(cwd, { fresh = false, projections = false } = {}) {
13
+ // A completed write invalidates its cache. Reading it synchronously here
14
+ // froze every HTTP client even though the write response was already sent.
15
+ // With no subscriber there is no projection to refresh at all.
16
+ for (const res of sseClients) if (res.destroyed || res.writableEnded) sseClients.delete(res);
17
+ const clients = [...sseClients].filter(res => res._gctoCwd === cwd);
18
+ if (!clients.length) return { ok: true, subscribers: 0 };
19
+ let msg, result;
20
+ try {
21
+ if (fresh && !checkBeadsAvailable(cwd)) {
22
+ const read = await warmTasksAsync(cwd);
23
+ if (!read.ok || read.discarded) throw Error('fresh snapshot unavailable');
24
+ }
25
+ const tasks = await getTasksAsync(cwd);
26
+ if (getReadDegradation(cwd)) throw Error('snapshot unavailable');
27
+ msg = `event: tasks\ndata: ${JSON.stringify(tasks)}\n\n`;
28
+ if (projections) {
29
+ // All projections describe the same snapshot, without another Beads read.
30
+ msg += `event: pipeline\ndata: ${JSON.stringify(getPipeline(cwd, { tasks }))}\n\n`;
31
+ msg += `event: inbox\ndata: ${JSON.stringify(getInbox(cwd, { tasks }))}\n\n`;
32
+ }
33
+ result = { ok: true };
34
+ } catch {
35
+ // Preserve the last visible tasks instead of publishing a fabricated [].
36
+ // Explicit transport state; clients retain the last visible snapshot.
37
+ result = { ok: false, state: 'unreadable' };
38
+ msg = `event: task-read-state\ndata: ${JSON.stringify(result)}\n\n`;
39
+ }
40
+ for (const res of clients) {
41
+ // A client may have disconnected or changed project while the read ran.
42
+ if (sseClients.has(res) && res._gctoCwd === cwd && !res.destroyed && !res.writableEnded) {
15
43
  try { res.write(msg); } catch { sseClients.delete(res); }
16
44
  }
17
45
  }
46
+ return result;
18
47
  }
19
48
 
20
49
  export { broadcast, broadcastTasks };
@@ -3,8 +3,8 @@ import path from 'path';
3
3
  import { GREAT_CTO_DIR } from './config.mjs';
4
4
  import { sseClients } from './state.mjs';
5
5
  import { listProjects } from './projects.mjs';
6
- import { bdCacheStale, getTasks, isSelfInflictedTouch } from './beads.mjs';
7
- import { getPipeline, getInbox } from './data-readers.mjs';
6
+ import { bdCacheStale, isSelfInflictedTouch } from './beads.mjs';
7
+ import { broadcastTasks } from './sse.mjs';
8
8
  import { agentActivitySince, activityStamp } from './agent-activity.mjs';
9
9
 
10
10
  // ── File watcher ───────────────────────────────────────────────────────────────
@@ -51,17 +51,9 @@ function watchBeads() {
51
51
  if (isSelfInflictedTouch(dir)) return;
52
52
  // Stale, not deleted. A change we NOTICED must not make the next reader pay
53
53
  // for it — they asked for something else. The broadcast below still reads
54
- // fresh, because a stale entry refreshes on read.
54
+ // fresh, because it explicitly awaits the shared asynchronous refresh.
55
55
  bdCacheStale(dir);
56
- for (const res of sseClients) {
57
- if (res._gctoCwd === dir) {
58
- try {
59
- res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(dir))}\n\n`);
60
- res.write(`event: pipeline\ndata: ${JSON.stringify(getPipeline(dir))}\n\n`);
61
- res.write(`event: inbox\ndata: ${JSON.stringify(getInbox(dir))}\n\n`);
62
- } catch {}
63
- }
64
- }
56
+ void broadcastTasks(dir, { fresh: true, projections: true });
65
57
  };
66
58
 
67
59
  // Debounce per-dir: dolt writes can fire 3-5 events in <50ms during a single
@@ -130,12 +122,9 @@ function watchVerdicts() {
130
122
  if (pushTimer) clearTimeout(pushTimer);
131
123
  // debounce: collapse a burst of writes (multiple agents finishing within ~150ms)
132
124
  pushTimer = setTimeout(() => {
133
- for (const res of sseClients) {
134
- const dir = res._gctoCwd || process.cwd();
135
- try {
136
- res.write(`event: pipeline\ndata: ${JSON.stringify(getPipeline(dir))}\n\n`);
137
- res.write(`event: inbox\ndata: ${JSON.stringify(getInbox(dir))}\n\n`);
138
- } catch { sseClients.delete(res); }
125
+ const dirs = new Set([...sseClients].map(res => res._gctoCwd || process.cwd()));
126
+ for (const dir of dirs) {
127
+ void broadcastTasks(dir, { projections: true });
139
128
  }
140
129
  }, 150);
141
130
  };
@@ -1768,8 +1768,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1768
1768
  .pl-gate.active .pl-msg, .pl-gate.active .pl-meta { opacity: 1; color: var(--text); }
1769
1769
  .pl-gate .gate-badge {
1770
1770
  margin-left: auto;
1771
- background: var(--status-blocked); color: var(--on-solid);
1772
- font-size: var(--fs-eyebrow); font-weight: 700;
1771
+ background: var(--status-blocked); color: var(--on-status);
1772
+ font-size: var(--fs-eyebrow); font-weight: 500;
1773
1773
  min-width: 16px; height: 16px; padding: 0 4px;
1774
1774
  border-radius: 0;
1775
1775
  display: inline-flex; align-items: center; justify-content: center;
@@ -8117,6 +8117,12 @@ function connectSSE() {
8117
8117
  };
8118
8118
  es.addEventListener('tasks', e => {
8119
8119
  const newTasks = JSON.parse(e.data);
8120
+ delete BOARD_DEGRADED['/api/tasks'];
8121
+ renderDegradedBanner('degraded-read', null, '');
8122
+ if (es.readyState === EventSource.OPEN) {
8123
+ document.getElementById('live-dot').className = 'live-dot pulse';
8124
+ document.getElementById('live-label').textContent = 'live · synced just now';
8125
+ }
8120
8126
  // A transient bd failure (dolt lock, timeout) can produce an empty
8121
8127
  // snapshot indistinguishable from "no tasks" (great_cto-e2ew). Don't
8122
8128
  // blindly wipe a populated board — refetch once via the regular API
@@ -8130,6 +8136,20 @@ function connectSSE() {
8130
8136
  }
8131
8137
  applyTasksSnapshot(newTasks);
8132
8138
  });
8139
+ es.addEventListener('task-read-state', e => {
8140
+ try {
8141
+ const state = JSON.parse(e.data);
8142
+ if (state.state !== 'unreadable') return;
8143
+ BOARD_DEGRADED['/api/tasks'] = 'Task snapshot is unavailable. Showing the last loaded tasks.';
8144
+ document.getElementById('live-dot').className = 'live-dot error';
8145
+ document.getElementById('live-label').textContent = 'connected · task data unavailable';
8146
+ // Re-evaluate existing healthy claims as well as adding the banner.
8147
+ // A previously painted "All clear" must not survive a failed refresh.
8148
+ if (window.__inboxData) renderInbox(window.__inboxData);
8149
+ renderDegradedBanner('degraded-read', degradedFor('/api/tasks'),
8150
+ 'Project data could not be refreshed. Counts may be out of date.');
8151
+ } catch { /* Malformed transport data must not erase the last snapshot. */ }
8152
+ });
8133
8153
  es.addEventListener('pipeline', e => {
8134
8154
  try {
8135
8155
  const stages = JSON.parse(e.data);
@@ -8165,8 +8185,10 @@ function connectSSE() {
8165
8185
  });
8166
8186
  es.onopen = () => {
8167
8187
  const d = document.getElementById('live-dot');
8168
- d.className = 'live-dot pulse';
8169
- document.getElementById('live-label').textContent = 'live · synced just now';
8188
+ const unreadable = degradedFor('/api/tasks');
8189
+ d.className = unreadable ? 'live-dot error' : 'live-dot pulse';
8190
+ document.getElementById('live-label').textContent = unreadable
8191
+ ? 'connected · task data unavailable' : 'live · synced just now';
8170
8192
  };
8171
8193
  es.onerror = () => {
8172
8194
  document.getElementById('live-dot').className = 'live-dot error';
@@ -88,9 +88,34 @@ const excludeLogs = ACTIVITY_LOGS.map((p) => `:(exclude)${p}`);
88
88
  * Returns `null` outside a git repository rather than a fabricated receipt: a
89
89
  * receipt that cannot be built must not look like one that matched.
90
90
  */
91
- export function treeReceipt(cwd = process.cwd(), { base = null, maxFiles = MAX_FILES, verdictsDir = null, exclude = [] } = {}) {
91
+ export function treeReceipt(cwd = process.cwd(), { base = null, maxFiles = MAX_FILES, verdictsDir = null, exclude = [], hashFilesInProcess = false } = {}) {
92
92
  const head = git(['rev-parse', 'HEAD'], cwd)?.trim();
93
93
  if (!head) return null;
94
+ // Off-loop board readers avoid one process per untracked file. Preserve Git's
95
+ // blob format (including SHA-256 repositories) and --no-filters semantics.
96
+ const algorithm = hashFilesInProcess ? git(['rev-parse', '--show-object-format'], cwd)?.trim() : null;
97
+ if (hashFilesInProcess && !['sha1', 'sha256'].includes(algorithm)) return null;
98
+ const digest = p => {
99
+ if (!hashFilesInProcess) return fileDigest(cwd, p);
100
+ try {
101
+ const fd = fsModule.openSync(join(cwd, p), 'r');
102
+ try {
103
+ const before = fsModule.fstatSync(fd, { bigint: true });
104
+ if (!before.isFile()) return null;
105
+ const hash = createHash(algorithm).update(`blob ${before.size}\0`);
106
+ const chunk = Buffer.allocUnsafe(64 * 1024);
107
+ let remaining = before.size;
108
+ while (remaining > 0n) {
109
+ const count = fsModule.readSync(fd, chunk, 0, Number(remaining > BigInt(chunk.length) ? BigInt(chunk.length) : remaining), null);
110
+ if (!count) return null;
111
+ hash.update(chunk.subarray(0, count)); remaining -= BigInt(count);
112
+ }
113
+ const after = fsModule.fstatSync(fd, { bigint: true });
114
+ if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) return null;
115
+ return hash.digest('hex');
116
+ } finally { fsModule.closeSync(fd); }
117
+ } catch { return null; }
118
+ };
94
119
 
95
120
  // Directories a caller does not want in this receipt, ON TOP OF the activity logs.
96
121
  // ADR-024: a gate token binds to the project outside .great_cto/, because an
@@ -121,7 +146,9 @@ export function treeReceipt(cwd = process.cwd(), { base = null, maxFiles = MAX_F
121
146
  if (diff === null || untrackedOutput === null) return null;
122
147
  const untracked = untrackedOutput
123
148
  .split('\n').map((s) => s.trim()).filter(Boolean);
124
- const untrackedDigest = untracked.map((p) => `${p}:${fileDigest(cwd, p) ?? '?'}`).join('\n');
149
+ const untrackedDigests = untracked.map(p => [p, digest(p)]);
150
+ if (hashFilesInProcess && untrackedDigests.some(([, value]) => !value)) return null;
151
+ const untrackedDigest = untrackedDigests.map(([p, value]) => `${p}:${value ?? '?'}`).join('\n');
125
152
  const dirty = (diff.trim() || untrackedDigest) ? sha(`${diff}\n--untracked--\n${untrackedDigest}`) : null;
126
153
 
127
154
  // Which files the change touches. `--diff-filter=d` drops deletions: a file
@@ -170,7 +197,7 @@ export function treeReceipt(cwd = process.cwd(), { base = null, maxFiles = MAX_F
170
197
  let truncated = false;
171
198
  for (const p of names) {
172
199
  if (Object.keys(files).length >= maxFiles) { truncated = true; break; }
173
- const blob = fileDigest(cwd, p);
200
+ const blob = digest(p);
174
201
  if (!blob) return null;
175
202
  files[p] = blob;
176
203
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.62.0",
3
+ "version": "3.62.2",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",