great-cto 3.62.0 → 3.62.1
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/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/fixtures/beads-read-owner.mjs +3 -0
- package/board/packages/board/fixtures/fake-bd-controlled.mjs +49 -0
- package/board/packages/board/lib/alerts.mjs +21 -7
- package/board/packages/board/lib/bd-read-worker.mjs +64 -0
- package/board/packages/board/lib/beads.mjs +138 -35
- package/board/packages/board/lib/data-readers.mjs +9 -9
- package/board/packages/board/lib/metrics.mjs +1 -2
- package/board/packages/board/lib/routes.mjs +44 -8
- package/board/packages/board/lib/share.mjs +4 -2
- package/board/packages/board/lib/sse.mjs +34 -5
- package/board/packages/board/lib/watchers.mjs +7 -18
- package/board/packages/board/public/index.html +26 -4
- package/package.json +1 -1
|
@@ -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.
|
|
4
|
+
"version": "3.62.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Alexander Velikiy",
|
|
7
7
|
"url": "https://hashnode.com/@Greatcto"
|
|
@@ -0,0 +1,49 @@
|
|
|
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 (['hang', 'leader-exit'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
|
|
20
|
+
process.on('SIGTERM', () => {});
|
|
21
|
+
spawn(process.execPath, [new URL(import.meta.url).pathname, '--descendant'], { stdio: 'inherit' });
|
|
22
|
+
if (process.env.FAKE_BD_CONTROL_MODE === 'leader-exit') {
|
|
23
|
+
setInterval(() => { if (fs.existsSync(marker('descendant'))) process.exit(0); }, 25);
|
|
24
|
+
}
|
|
25
|
+
setInterval(() => {}, 1000);
|
|
26
|
+
} else if (process.env.FAKE_BD_CONTROL_MODE === 'large-success') {
|
|
27
|
+
process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'x'.repeat(131072), status: 'open', priority: 0 }]), () => {
|
|
28
|
+
fs.writeFileSync(marker('output-written'), 'ready');process.exit(0);
|
|
29
|
+
});
|
|
30
|
+
} else if (['closed-success', 'closed-fail'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
|
|
31
|
+
const descendant = spawn(process.execPath, [new URL(import.meta.url).pathname, '--descendant'], { stdio: 'ignore' });
|
|
32
|
+
descendant.unref();
|
|
33
|
+
const ready = setInterval(() => {
|
|
34
|
+
if (!fs.existsSync(marker('descendant'))) return;
|
|
35
|
+
clearInterval(ready);
|
|
36
|
+
process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'Fixture task', status: 'open', priority: 0 }]),
|
|
37
|
+
() => process.exit(process.env.FAKE_BD_CONTROL_MODE === 'closed-fail' ? 1 : 0));
|
|
38
|
+
}, 25);
|
|
39
|
+
} else if (process.env.FAKE_BD_CONTROL_MODE === 'oversize') {
|
|
40
|
+
process.stdout.write(Buffer.alloc(16 * 1024 * 1024 + 1, 120));
|
|
41
|
+
setInterval(() => {}, 1000);
|
|
42
|
+
} else {
|
|
43
|
+
const timer = setInterval(() => {
|
|
44
|
+
if (!fs.existsSync(marker('release'))) return;
|
|
45
|
+
clearInterval(timer);
|
|
46
|
+
process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'Fixture task', status: 'open', priority: 0 }]));
|
|
47
|
+
}, 25);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,64 @@
|
|
|
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, ...options }) {
|
|
7
|
+
return fork(entry, [binary, String(readDeadlineMs)], { ...options, silent: true });
|
|
8
|
+
}
|
|
9
|
+
if (process.argv[1] === entry) {
|
|
10
|
+
const grouped = process.platform !== 'win32';
|
|
11
|
+
let child;
|
|
12
|
+
function killOwnedGroup() {
|
|
13
|
+
// POSIX fork is detached: the worker PID is exclusively this read's PGID.
|
|
14
|
+
if (grouped) {
|
|
15
|
+
try { process.kill(-process.pid, 'SIGKILL'); } catch { process.exit(1); }
|
|
16
|
+
} else {
|
|
17
|
+
try { child?.kill('SIGKILL'); } catch { /* already gone */ }
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
process.on('disconnect', killOwnedGroup);
|
|
22
|
+
process.on('SIGTERM', () => {}); // keep the independent KILL deadline alive
|
|
23
|
+
process.stdout.on('error', killOwnedGroup);
|
|
24
|
+
process.stderr.on('error', killOwnedGroup);
|
|
25
|
+
// Independent upper bound even if the parent's event loop is stalled.
|
|
26
|
+
const budget = Number(process.argv[3]);
|
|
27
|
+
if (!Number.isFinite(budget) || budget < 1 || budget > 20250) process.exit(2);
|
|
28
|
+
setTimeout(killOwnedGroup, budget);
|
|
29
|
+
let completing = false;
|
|
30
|
+
function complete(code) {
|
|
31
|
+
if (completing) return;
|
|
32
|
+
completing = true;
|
|
33
|
+
// A successful leader can leave descendants with closed/ignored pipes.
|
|
34
|
+
// Flush the result over IPC before killing the whole owned group. Keep
|
|
35
|
+
// the independent deadline alive until cleanup, even if IPC stalls.
|
|
36
|
+
if (!process.connected) return killOwnedGroup();
|
|
37
|
+
// child 'close' means its pipes ended, not that this worker's output
|
|
38
|
+
// queues reached the parent. Flush before announcing success or KILL
|
|
39
|
+
// can truncate an otherwise valid snapshot under pipe backpressure.
|
|
40
|
+
try { process.stdout.write('', stdoutError => {
|
|
41
|
+
if (stdoutError) return killOwnedGroup();
|
|
42
|
+
process.stderr.write('', stderrError => {
|
|
43
|
+
if (stderrError || !process.connected) return killOwnedGroup();
|
|
44
|
+
try { process.send({ type: 'bd-read-result', code }, () => killOwnedGroup()); }
|
|
45
|
+
catch { killOwnedGroup(); }
|
|
46
|
+
});
|
|
47
|
+
}); }
|
|
48
|
+
catch { killOwnedGroup(); }
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
child = spawn(process.argv[2], ['list', '--json', '--all', '--include-gates'], {
|
|
52
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
53
|
+
});
|
|
54
|
+
child.stdout.pipe(process.stdout, { end: false });
|
|
55
|
+
child.stderr.pipe(process.stderr, { end: false });
|
|
56
|
+
child.on('error', () => complete(1));
|
|
57
|
+
child.on('close', code => {
|
|
58
|
+
// No background descendants are part of a completed read contract.
|
|
59
|
+
// Wait for inherited pipes too: exiting on leader 'exit' would remove
|
|
60
|
+
// the IPC watchdog while a descendant was still holding those pipes.
|
|
61
|
+
complete(Number.isInteger(code) ? code : 1);
|
|
62
|
+
});
|
|
63
|
+
} catch { complete(1); }
|
|
64
|
+
}
|
|
@@ -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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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(
|
|
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
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
401
|
+
finish({ ok: false });
|
|
333
402
|
});
|
|
334
403
|
child.on('close', (code) => {
|
|
335
|
-
|
|
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}`);
|
|
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
|
-
|
|
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 = (
|
|
430
|
+
const supersededByInvalidation = (invalidationGeneration.get(cwd) || 0) !== generation;
|
|
357
431
|
const supersededByFresherEntry = (bdCache.get(cwd)?.ts || 0) >= startedAt;
|
|
358
432
|
if (supersededByInvalidation || supersededByFresherEntry) {
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 };
|
|
@@ -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.
|
|
@@ -27,9 +27,9 @@ 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
35
|
import { issueTokens, checkToken, checkBinding, consumeToken } from './gate-tokens.mjs';
|
|
@@ -124,6 +124,32 @@ async function dispatch(req, res, url, cwd) {
|
|
|
124
124
|
const scopedProject = ['/api/skills', '/api/usage', '/api/outcomes'].includes(pathname)
|
|
125
125
|
? scopedReadProject(cwd, serverCwd, requestedProject) : null;
|
|
126
126
|
|
|
127
|
+
// Existing synchronous projections may consume a completed snapshot, but
|
|
128
|
+
// HTTP cold reads must await async I/O instead of freezing unrelated clients.
|
|
129
|
+
const taskRead = ['/api/work', '/api/sse', '/api/tasks', '/api/metrics',
|
|
130
|
+
'/api/memory', '/api/pipeline', '/api/heartbeat', '/api/share', '/api/cost', '/api/resume'].includes(pathname)
|
|
131
|
+
|| /^\/api\/tasks\/[^/]+\/history$/.test(pathname);
|
|
132
|
+
const unknownWork = pathname === '/api/work' && requestedProject
|
|
133
|
+
&& resolveProjectInfo(requestedProject).resolved === 'fallback';
|
|
134
|
+
let sseClosed = false;
|
|
135
|
+
if (pathname === '/api/sse') {
|
|
136
|
+
// Install lifecycle ownership before the cold snapshot await. Request
|
|
137
|
+
// 'close' also occurs for normally completed GET bodies; response close
|
|
138
|
+
// is the actual stream lifetime signal.
|
|
139
|
+
res.once('close', () => { sseClosed = true; sseClients.delete(res); });
|
|
140
|
+
}
|
|
141
|
+
const closedSse = () => pathname === '/api/sse'
|
|
142
|
+
&& (sseClosed || req.aborted || res.destroyed || res.writableEnded);
|
|
143
|
+
if (req.method === 'GET' && taskRead && !unknownWork) {
|
|
144
|
+
try { await getTasksAsync(cwd); }
|
|
145
|
+
catch {
|
|
146
|
+
if (closedSse()) return true;
|
|
147
|
+
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
148
|
+
res.end(JSON.stringify({ error: 'Task snapshot is unavailable; retry', state: 'unreadable' }));
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
127
153
|
if (pathname === '/api/work/publication') {
|
|
128
154
|
const respond = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
|
|
129
155
|
if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') { respond(404, { error: 'Unknown project' }); return true; }
|
|
@@ -222,6 +248,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
222
248
|
|
|
223
249
|
// SSE
|
|
224
250
|
if (pathname === '/api/sse') {
|
|
251
|
+
if (closedSse()) return true;
|
|
225
252
|
res.writeHead(200, {
|
|
226
253
|
'Content-Type': 'text/event-stream',
|
|
227
254
|
'Cache-Control': 'no-cache',
|
|
@@ -229,7 +256,11 @@ async function dispatch(req, res, url, cwd) {
|
|
|
229
256
|
});
|
|
230
257
|
res._gctoCwd = cwd; // remember which project this client wants
|
|
231
258
|
sseClients.add(res);
|
|
232
|
-
|
|
259
|
+
if (getReadDegradation(cwd)) {
|
|
260
|
+
res.write(`event: task-read-state\ndata: ${JSON.stringify({ ok: false, state: 'unreadable' })}\n\n`);
|
|
261
|
+
} else {
|
|
262
|
+
res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`);
|
|
263
|
+
}
|
|
233
264
|
// ADR-021: the agent activity strip starts from a snapshot, then follows pushes.
|
|
234
265
|
// Phase 2: a client that hands back the cursor of the last frame it saw — the
|
|
235
266
|
// Last-Event-ID header, or ?since= from a page that had to recreate its
|
|
@@ -243,7 +274,6 @@ async function dispatch(req, res, url, cwd) {
|
|
|
243
274
|
}
|
|
244
275
|
res._gctoAgentCursor = a.cursor;
|
|
245
276
|
res.write(`${a.cursor ? `id: ${a.cursor}\n` : ''}event: agent\ndata: ${JSON.stringify(a)}\n\n`);
|
|
246
|
-
req.on('close', () => sseClients.delete(res));
|
|
247
277
|
return true;
|
|
248
278
|
}
|
|
249
279
|
|
|
@@ -718,7 +748,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
718
748
|
// Without this, concurrent approve+reject on the same gate produced
|
|
719
749
|
// TWO appendDecisionLog entries (one wrong) — log says approved AND
|
|
720
750
|
// rejected. bdWriteSerialised guarantees one-at-a-time semantics.
|
|
721
|
-
const result = await bdWriteSerialised(() => {
|
|
751
|
+
const result = await bdWriteSerialised(async () => {
|
|
722
752
|
const status = action === 'approve' ? 'closed' : 'blocked';
|
|
723
753
|
let via = 'beads';
|
|
724
754
|
// Try beads first (unless there's no store at all); on any bd failure
|
|
@@ -761,7 +791,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
761
791
|
const projectSlug = parsed.project && !/^[\/~]/.test(parsed.project) ? parsed.project : path.basename(gateCwd);
|
|
762
792
|
let title = id;
|
|
763
793
|
try {
|
|
764
|
-
const gateTask =
|
|
794
|
+
const gateTask = (await getTasksAsync(gateCwd)).find((t) => t.id === id);
|
|
765
795
|
if (gateTask?.title) title = gateTask.title;
|
|
766
796
|
} catch { /* the id is a documented fallback; the record still gets written */ }
|
|
767
797
|
gateTitle = title;
|
|
@@ -847,7 +877,13 @@ async function dispatch(req, res, url, cwd) {
|
|
|
847
877
|
|
|
848
878
|
// Inbox — what needs your attention right now
|
|
849
879
|
if (pathname === '/api/inbox') {
|
|
850
|
-
|
|
880
|
+
let inbox;
|
|
881
|
+
try { inbox = await getInboxAsync(cwd); }
|
|
882
|
+
catch {
|
|
883
|
+
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
884
|
+
res.end(JSON.stringify({ error: 'Task snapshot is unavailable; retry', state: 'unreadable' }));
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
851
887
|
// BRD-R3: the Decisions row shows both reviewers. The second opinion is a
|
|
852
888
|
// fact about the TREE, not about a gate — every pending gate on this tree
|
|
853
889
|
// shares it — so it is resolved once: the newest cross-review line whose
|
|
@@ -861,7 +897,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
861
897
|
// If the registry itself cannot be walked, say so — `unreadable` is not
|
|
862
898
|
// `{p0: 0}`, and the page must not print "nothing elsewhere" from it.
|
|
863
899
|
let elsewhere;
|
|
864
|
-
try { elsewhere = inboxElsewhere(listProjects(), cwd
|
|
900
|
+
try { elsewhere = inboxElsewhere(listProjects(), cwd); }
|
|
865
901
|
catch (e) { elsewhere = { state: 'unreadable', why: String(e?.message || e) }; }
|
|
866
902
|
// ADR-024 §1: each pending gate carries the token an approval must present,
|
|
867
903
|
// bound to the project as it is now. The typed name stored with it is the one
|
|
@@ -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 {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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,
|
|
7
|
-
import {
|
|
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
|
|
54
|
+
// fresh, because it explicitly awaits the shared asynchronous refresh.
|
|
55
55
|
bdCacheStale(dir);
|
|
56
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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-
|
|
1772
|
-
font-size: var(--fs-eyebrow); font-weight:
|
|
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
|
-
|
|
8169
|
-
|
|
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';
|
package/package.json
CHANGED