great-cto 3.15.0 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "name": "great_cto",
3
3
  "id": "great_cto",
4
4
  "description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
5
- "version": "3.15.0",
5
+ "version": "3.17.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -0,0 +1,9 @@
1
+ #!/bin/bash
2
+ # Test fixture: a `bd` stand-in that always exits non-zero, for testing the
3
+ # failure path of warmTasksAsync (packages/board/beads-warm-async.test.mjs).
4
+ if [ "$1" = "--version" ]; then
5
+ echo "bd-fake 0.0.0"
6
+ exit 0
7
+ fi
8
+ echo "dolt: database is locked" >&2
9
+ exit 1
@@ -0,0 +1,12 @@
1
+ #!/bin/bash
2
+ # Test fixture: a `bd` stand-in that sleeps before answering, so the
3
+ # boot-responsiveness tests can prove the board stays answerable DURING a
4
+ # slow `bd list` — not just that the code calls the right function. Loaded
5
+ # only via GREAT_CTO_BD_BIN in packages/board/*.test.mjs; never used outside
6
+ # tests.
7
+ if [ "$1" = "--version" ]; then
8
+ echo "bd-fake 0.0.0"
9
+ exit 0
10
+ fi
11
+ sleep "${FAKE_BD_DELAY_SECS:-3}"
12
+ echo "[]"
@@ -12,14 +12,20 @@ import { listProjects, readProjectMd } from './projects.mjs';
12
12
  import { addNotification } from './notifications.mjs';
13
13
  import { getMetrics } from './metrics.mjs';
14
14
  import { getCostHistory, getInbox } from './data-readers.mjs';
15
- // Every sweep below runs over EVERY registered project, and `bd list` is
16
- // spawnSync the event loop is held for the whole of it. At 16 projects and
17
- // 2-6 s each that is ~60 s per sweep, and three of these fire every five
18
- // minutes. Sweeps therefore read at sweep freshness, not interactive freshness:
19
- // see SWEEP_MAX_AGE_MS in beads.mjs for the arithmetic that made the board
20
- // unanswerable while reporting "live · synced just now".
15
+ // Every sweep below runs over EVERY registered project. `sweep: true` opts a
16
+ // COLD project (never read this process) into bdList()'s non-blocking fill
17
+ // path instead of its synchronous one the same escape hatch a WARM-but-
18
+ // stale entry already gets. Without it: a real board's first cron tick
19
+ // (t=+5:10, no request in flight) lagged the event loop 9.8s reading a
20
+ // single project synchronously; at 16 projects and 2-6s each that is ~60s
21
+ // per sweep, three of which fire every five minutes, indefinitely. Sweeps
22
+ // read at sweep freshness, not interactive freshness — see SWEEP_MAX_AGE_MS
23
+ // in beads.mjs for the arithmetic that made the board unanswerable while
24
+ // reporting "live · synced just now". `sweep: true` must NEVER be added to
25
+ // an interactive read's opts: an ordinary request is someone waiting on
26
+ // THIS project's answer, and the fill path answers `[]` instead.
21
27
  import { getTasks, SWEEP_MAX_AGE_MS } from './beads.mjs';
22
- const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS };
28
+ const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS, sweep: true };
23
29
  import { readVerdicts } from './verdicts.mjs';
24
30
  import { isFailure } from './fleet.mjs';
25
31
  import { getShareState, toggleShare } from './share.mjs';
@@ -38,6 +38,15 @@ import { log } from './log.mjs';
38
38
  // because each request arrived just after the entry expired.
39
39
  const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
40
40
 
41
+ /**
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.
47
+ */
48
+ const lastInvalidatedAt = new Map();
49
+
41
50
  /**
42
51
  * Drop a directory's entry so the next read is guaranteed fresh.
43
52
  *
@@ -47,6 +56,7 @@ const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
47
56
  */
48
57
  function bdCacheInvalidate(cwd) {
49
58
  clearSelfTouch(cwd);
59
+ lastInvalidatedAt.set(cwd, Date.now());
50
60
  bdCache.delete(cwd);
51
61
  }
52
62
 
@@ -63,6 +73,7 @@ function bdCacheInvalidate(cwd) {
63
73
  */
64
74
  function bdCacheStale(cwd) {
65
75
  clearSelfTouch(cwd);
76
+ lastInvalidatedAt.set(cwd, Date.now());
66
77
  const cached = bdCache.get(cwd);
67
78
  if (cached) bdCache.set(cwd, { ...cached, ts: 0 });
68
79
  }
@@ -290,13 +301,16 @@ const refreshing = new Set();
290
301
  * measured at 1-10 s because it was queued behind a task read. Warming at boot
291
302
  * moved the first stall out of sight; this removes the rest.
292
303
  *
293
- * Nothing awaits it. It exists to make the NEXT read fast, and a caller that
294
- * needed the new data would have had to block for it anyway.
304
+ * `onDone`, if given, fires exactly once at every exit point (success,
305
+ * failure, or "already refreshing"). Every existing caller omits it and stays
306
+ * fire-and-forget — the ONE caller that wants to know when the fill finished
307
+ * is the boot warm-up (see `warmTasksAsync`), which logs how long it took.
295
308
  */
296
- function bdRefreshAsync(cwd) {
297
- if (refreshing.has(cwd)) return;
309
+ function bdRefreshAsync(cwd, onDone = () => {}) {
310
+ if (refreshing.has(cwd)) { onDone({ ok: false, skipped: true }); return; }
298
311
  refreshing.add(cwd);
299
- lastBdRunAt.set(cwd, Date.now());
312
+ const startedAt = Date.now();
313
+ lastBdRunAt.set(cwd, startedAt);
300
314
  let out = '';
301
315
  try {
302
316
  const child = spawn(BD_BIN, ['list', '--json', '--all', '--include-gates'], { cwd, env: bdEnv() });
@@ -304,31 +318,87 @@ function bdRefreshAsync(cwd) {
304
318
  child.on('error', (e) => {
305
319
  refreshing.delete(cwd);
306
320
  bdFailures.set(cwd, `bd could not be run: ${e?.message || e}`.slice(0, 300));
321
+ onDone({ ok: false });
307
322
  });
308
323
  child.on('close', (code) => {
309
324
  refreshing.delete(cwd);
310
325
  lastBdRunAt.set(cwd, Date.now());
311
- if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); return; }
326
+ if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); onDone({ ok: false }); return; }
312
327
  try {
313
328
  const parsed = JSON.parse(out || '[]');
314
329
  // Same guard as the sync path: bd 0.6x can answer 0 with a JSON object,
315
330
  // and a non-array rendered as no tasks is the silent zero again.
316
331
  if (!Array.isArray(parsed)) {
317
332
  bdFailures.set(cwd, String(parsed?.error || 'bd returned something that is not a task list').slice(0, 300));
333
+ onDone({ ok: false });
334
+ return;
335
+ }
336
+ // This fetch answers "what was true at `startedAt`?" — if anyone has
337
+ // since told the cache it's wrong (a write's invalidate/stale-mark)
338
+ // or already written something newer (a synchronous cold read that
339
+ // raced us and won), our answer is superseded. Committing it now
340
+ // would silently resurrect data from before a change a caller is
341
+ // already waiting to see reflected — exactly what made
342
+ // `tests/pipeline-e2e.test.mjs`'s gate-approval assertions flake once
343
+ // this function started running from a COLD cache (the boot warm-up)
344
+ // instead of only ever refreshing an already-populated one.
345
+ const supersededByInvalidation = (lastInvalidatedAt.get(cwd) || 0) >= startedAt;
346
+ const supersededByFresherEntry = (bdCache.get(cwd)?.ts || 0) >= startedAt;
347
+ if (supersededByInvalidation || supersededByFresherEntry) {
348
+ onDone({ ok: true, count: parsed.length, discarded: true });
318
349
  return;
319
350
  }
320
351
  bdFailures.delete(cwd);
321
352
  bdCache.set(cwd, { ts: Date.now(), data: parsed });
353
+ onDone({ ok: true, count: parsed.length });
322
354
  } catch (e) {
323
355
  bdFailures.set(cwd, `bd output could not be parsed: ${e?.message || e}`.slice(0, 300));
356
+ onDone({ ok: false });
324
357
  }
325
358
  });
326
359
  } catch (e) {
327
360
  refreshing.delete(cwd);
328
361
  bdFailures.set(cwd, `bd could not be spawned: ${e?.message || e}`.slice(0, 300));
362
+ onDone({ ok: false });
329
363
  }
330
364
  }
331
365
 
366
+ /**
367
+ * Warm a directory's task cache at boot, WITHOUT blocking the event loop.
368
+ *
369
+ * The boot warm-up used to call `getTasks(cwd)` directly — `bdList()`'s
370
+ * COLD-cache path, which is `bd()`'s synchronous `spawnSync`, because a cold
371
+ * cache has nothing to serve while a background refresh runs, so it has
372
+ * always been the one path in this file that blocks. That's the right call
373
+ * for a REQUEST (something is waiting and there's no stale data to fall back
374
+ * on); it was the wrong call at boot, because a boot warm-up has nobody
375
+ * waiting on it — its only job is to be ready before someone asks.
376
+ *
377
+ * Measured with an artificially slow `bd` (GREAT_CTO_BD_BIN pointed at a
378
+ * fixture that sleeps N seconds before answering): the board bound its port,
379
+ * then answered `000` on EVERY endpoint — including /api/version, a plain
380
+ * readdirSync with nothing to do with bd — for the fixture's entire delay.
381
+ * The port being open did not matter; the single process serving it was
382
+ * blocked inside `spawnSync` the whole time. `bd()`'s own 8s spawnSync
383
+ * timeout capped that run at ~8s; a real cold Dolt store or lock contention
384
+ * has no such cap, which is consistent with the up-to-2-minute stalls
385
+ * reported in production.
386
+ *
387
+ * This calls `bdRefreshAsync` — the SAME `spawn`-based, non-blocking path
388
+ * already used for warm-cache "stale-while-revalidate" reads — for the cold
389
+ * boot case too. The returned Promise exists only so the boot log can report
390
+ * how long the fill took; nothing waits on it, and the server is accepting
391
+ * and answering connections for the entire duration either way.
392
+ *
393
+ * @returns {Promise<{ok: boolean, count?: number, skipped?: boolean, ms: number}>}
394
+ */
395
+ function warmTasksAsync(cwd) {
396
+ const t0 = Date.now();
397
+ return new Promise((resolve) => {
398
+ bdRefreshAsync(cwd, (result) => resolve({ ...result, ms: Date.now() - t0 }));
399
+ });
400
+ }
401
+
332
402
  function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
333
403
  const maxAge = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : BD_CACHE_TTL_MS;
334
404
  const cached = bdCache.get(cwd);
@@ -338,14 +408,7 @@ function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
338
408
  if (cached && Date.now() - cached.ts < ttl) return cached.data;
339
409
 
340
410
  // Stale-while-revalidate. An entry that exists is served immediately, however
341
- // old, and refreshed off the event loop. Only a directory with NO entry at all
342
- // blocks — which after the boot warm-up means a project nobody has opened yet,
343
- // once.
344
- //
345
- // The alternative was making this async and every caller with it: getTasks,
346
- // getInbox, getPipeline, the metrics readers, the SSE broadcast, the alert
347
- // sweeps. That refactor is the correct end state and is not what a board
348
- // hanging today needs.
411
+ // old, and refreshed off the event loop.
349
412
  //
350
413
  // The injected `runner` is how the tests drive this. When one is supplied the
351
414
  // sync path is kept, so a test that stubs `bd` still observes the call it
@@ -354,6 +417,32 @@ function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
354
417
  bdRefreshAsync(cwd);
355
418
  return cached.data;
356
419
  }
420
+
421
+ // A directory with NO entry at all — genuinely never read. Left
422
+ // synchronous for an ordinary caller: a request FOR this project is
423
+ // waiting on the answer, and returning `[]` while a background fill
424
+ // catches up would be wrong data handed to a caller who cannot tell it
425
+ // apart from "no tasks" — confirmed the hard way. This branch originally
426
+ // covered every caller, and tests/board-gate.test.mjs,
427
+ // tests/pipeline-e2e.test.mjs and tests/resume-e2e.test.mjs all went red:
428
+ // a gate approved seconds after project creation read back as "no gates",
429
+ // because the read that should have shown it took the async path and
430
+ // answered empty instead.
431
+ //
432
+ // `opts.sweep` is the one caller allowed to skip the wait: the alert cron,
433
+ // which already has its own looser freshness contract (SWEEP_MAX_AGE_MS)
434
+ // and iterates EVERY registered project on a timer nobody is blocking on.
435
+ // Measured live, unprompted: a real board's first cron tick (t=+5:10, no
436
+ // request in flight) lagged the event loop 9.8s doing exactly this
437
+ // synchronous cold read, once per registered project, every five minutes,
438
+ // indefinitely. For that caller specifically, the same non-blocking fill
439
+ // stale-while-revalidate already uses for a WARM entry is safe on a COLD
440
+ // one too — the cron does not need this tick's answer to be correct, only
441
+ // bounded in staleness, which the background fill still gives it.
442
+ if (!cached && runner === bd && opts.sweep) {
443
+ bdRefreshAsync(cwd);
444
+ return [];
445
+ }
357
446
  try {
358
447
  lastBdRunAt.set(cwd, Date.now());
359
448
  const result = runner(['list', '--json', '--all', '--include-gates'], { cwd });
@@ -743,6 +832,7 @@ export {
743
832
  checkBeadsAvailable,
744
833
  bdWriteSerialised,
745
834
  bdList,
835
+ warmTasksAsync,
746
836
  bdFailureFor,
747
837
  parseTasksMd,
748
838
  getReadDegradation,
@@ -114,8 +114,21 @@ function getPipeline(cwd = process.cwd()) {
114
114
  // signature. Surface that checkpoint AS A STAGE in the pipeline, sitting just
115
115
  // before the irreversible steps (devops/ship). It lights up when a gate is
116
116
  // awaiting a human — so the operator always sees where the rails are.
117
+ // Awaiting a signature and blocked are not the same thing, and this said they
118
+ // were. `getInbox`'s pendingGates excludes blocked gates — deliberately, and
119
+ // under test: in a tasks.md project a gate marked `blocked` carried raw_status
120
+ // 'open' and never left the inbox. This filter excluded only done/closed, so
121
+ // the same gate the tile reported as 0 PENDING DECISIONS was announced here as
122
+ // 1 GATE AWAITING SIGNATURE. Two counts of one concept, on one screen,
123
+ // disagreeing over a single word.
124
+ //
125
+ // Dropping it from the rail would have been the other wrong answer: a gate
126
+ // stuck for 29 days is exactly what the rail exists to show. It is still
127
+ // shown, and now it is named for what it is.
117
128
  const openGates = tasks.filter(t => t.is_gate && t.status !== 'done' && t.status !== 'closed' && t.raw_status !== 'closed');
118
- const pending = openGates.length;
129
+ const blockedGates = openGates.filter(t => t.raw_status === 'blocked' || t.status === 'blocked');
130
+ const awaitingGates = openGates.filter(t => !blockedGates.includes(t));
131
+ const pending = awaitingGates.length;
119
132
  const newestGate = openGates.reduce((acc, t) => {
120
133
  const ts = t.updated_at || t.created_at; return (!acc || (ts && ts > acc)) ? ts : acc;
121
134
  }, null);
@@ -123,11 +136,15 @@ function getPipeline(cwd = process.cwd()) {
123
136
  const gateNode = {
124
137
  stage: 'human-gate',
125
138
  is_human_gate: true,
126
- status: pending > 0 ? 'active' : 'idle',
139
+ // Blocked is not active: an active stage is one somebody can act on now.
140
+ status: pending > 0 ? 'active' : (blockedGates.length ? 'blocked' : 'idle'),
127
141
  pending,
142
+ blocked: blockedGates.length,
128
143
  last_message: pending > 0
129
144
  ? `${pending} gate${pending > 1 ? 's' : ''} awaiting signature`
130
- : 'no gate pending',
145
+ : (blockedGates.length
146
+ ? `${blockedGates.length} gate${blockedGates.length > 1 ? 's' : ''} blocked`
147
+ : 'no gate pending'),
131
148
  verdict: null,
132
149
  ts: newestGate,
133
150
  age_min: gateAgeMs != null ? Math.round(gateAgeMs / 60000) : null,
@@ -182,7 +182,7 @@ function walk(dir, root, out, depth = 0) {
182
182
  * 8. a bare README a name that describes nothing else
183
183
  *
184
184
  * The leading filename token outranks the directory, and that ordering was
185
- * bought: 14 `docs/architecture/ADR-0NN-*.md` files were being filed as
185
+ * bought: 14 `docs/adr/ADR-0NN-*.md` files were being filed as
186
186
  * Architecture, and `docs/design/PLAN-*.md` as Design. A directory is where a
187
187
  * project dumps a category; the front of a filename is what the author called
188
188
  * THIS document. Mid-name tokens rank below the directory instead, because