@nanobpm/nano-workforce 0.69.1 → 0.70.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.
Files changed (39) hide show
  1. package/AGENTS.md +18 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +10 -8
  4. package/app/agentic/cockpit/index.ts +18 -0
  5. package/app/agentic/cockpit/supply-boot-past.test.ts +519 -0
  6. package/app/agentic/cockpit/supply-boot.test.ts +34 -0
  7. package/app/agentic/cockpit/supply-boot.ts +256 -21
  8. package/app/agentic/cockpit/transcript-render.test.ts +110 -0
  9. package/app/agentic/cockpit/transcript-render.ts +136 -0
  10. package/app/agentic/cockpit/transcript-view.test.ts +61 -0
  11. package/app/agentic/cockpit/transcript-view.ts +131 -0
  12. package/app/agentic/families/relay.family.test.ts +103 -0
  13. package/app/agentic/families/relay.family.ts +74 -0
  14. package/app/agentic/transcript-read.test.ts +72 -0
  15. package/app/agentic/transcript-read.ts +161 -0
  16. package/app/blackboard.test.ts +15 -7
  17. package/app/blackboard.ts +4 -4
  18. package/app/convergeGate.test.ts +406 -0
  19. package/app/convergeGate.ts +48 -0
  20. package/app/github.ts +225 -0
  21. package/app/roundProgress.test.ts +229 -0
  22. package/app/roundProgress.ts +70 -0
  23. package/app/service.test.ts +18 -1
  24. package/app/service.ts +5 -1
  25. package/db/migrations/033_pr_round_head.sql +16 -0
  26. package/nano.app.json +8 -0
  27. package/openapi.yaml +267 -0
  28. package/operations/getAgenticTranscript.test.ts +165 -0
  29. package/operations/getAgenticTranscript.ts +42 -0
  30. package/operations/listAgenticTranscripts.test.ts +169 -0
  31. package/operations/listAgenticTranscripts.ts +61 -0
  32. package/package.json +1 -1
  33. package/pages/cockpit/cockpit.css +70 -0
  34. package/pages/cockpit/mount.js +254 -13
  35. package/pages/cockpit.page.json +1 -1
  36. package/prompts/review-round.md +55 -9
  37. package/resources/processes/convergence-loop.bpmn +236 -65
  38. package/workers/converge-gate/worker.ts +101 -0
  39. package/workers/progress-check/worker.ts +77 -0
@@ -0,0 +1,61 @@
1
+ // GET /app/api/agentic/transcripts → operationId `listAgenticTranscripts` (ADR 0056, H3 read path #222).
2
+ //
3
+ // The READ counterpart to the write-only transcript store (H3 #146): it lists the durable transcripts an
4
+ // ephemeral agent flushed on job completion, so an operator can review "what did that agent do" AFTER it
5
+ // is gone. Sourced from the mounted relay/transcript service's TranscriptStore (over `app.data`) and
6
+ // correlated via `app/agentic/correlation.ts` (best-effort — jobKey is always recovered from the stream
7
+ // id, engine context only while the job is still live). Feeds the cockpit "past sessions" view.
8
+ //
9
+ // Advisory read-only (ADR 0056): it NEVER gates a BPMN sequence flow. Optional filters (jobKey / process
10
+ // instance / plan / time) narrow the feed. The optional shared-secret guard mirrors getAgenticSupply:
11
+ // when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header; unset -> open.
12
+
13
+ import { currentCorrelation } from "../app/agentic/correlation.ts";
14
+ import { currentRelayTranscriptService } from "../app/agentic/families/relay.family.ts";
15
+ import { listTranscripts, type TranscriptFilter } from "../app/agentic/transcript-read.ts";
16
+ import { envVar } from "../app/version.ts";
17
+ import type { AgenticTranscriptList } from "../nano-generated/api-io.d.ts";
18
+ import { defineOperation } from "../nano-generated/operations.ts";
19
+
20
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
21
+
22
+ /** Reject an ISO-8601 filter that does not parse (a malformed since/until is a 400, not a silent no-op). */
23
+ function badInstant(value: string | undefined): boolean {
24
+ return value !== undefined && !Number.isFinite(Date.parse(value));
25
+ }
26
+
27
+ export default defineOperation("listAgenticTranscripts", async ({ query, req }, app) => {
28
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
29
+ app.log.warn("listAgenticTranscripts rejected: missing/invalid shared secret");
30
+ return { status: 401, body: { error: "unauthorized" } };
31
+ }
32
+
33
+ if (badInstant(query.since) || badInstant(query.until)) {
34
+ return { status: 400, body: { error: "invalid since/until: expected an ISO-8601 instant" } };
35
+ }
36
+
37
+ const service = currentRelayTranscriptService();
38
+ const store = service?.store;
39
+ if (!store) {
40
+ // The relay family has not mounted, or is running unpersisted (no DataLayer) - no transcripts to
41
+ // report, not an error (advisory).
42
+ const empty: AgenticTranscriptList = { count: 0, generatedAt: new Date().toISOString(), transcripts: [] };
43
+ return { status: 200, body: empty };
44
+ }
45
+
46
+ const filter: TranscriptFilter = {
47
+ ...(query.jobKey !== undefined ? { jobKey: query.jobKey } : {}),
48
+ ...(query.processInstanceKey !== undefined ? { processInstanceKey: query.processInstanceKey } : {}),
49
+ ...(query.planKey !== undefined ? { planKey: query.planKey } : {}),
50
+ ...(query.since !== undefined ? { since: query.since } : {}),
51
+ ...(query.until !== undefined ? { until: query.until } : {}),
52
+ };
53
+ const transcripts = listTranscripts(store, currentCorrelation(), filter);
54
+ const body: AgenticTranscriptList = {
55
+ count: transcripts.length,
56
+ generatedAt: new Date().toISOString(),
57
+ retentionMs: store.ephemeralRetentionMs,
58
+ transcripts,
59
+ };
60
+ return { status: 200, body };
61
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.69.1",
3
+ "version": "0.70.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -42,6 +42,7 @@
42
42
  }
43
43
 
44
44
  .cockpit-supply-region,
45
+ .cockpit-past-region,
45
46
  .cockpit-terminal {
46
47
  background: var(--cockpit-panel);
47
48
  border: 1px solid var(--cockpit-edge);
@@ -160,3 +161,72 @@
160
161
  background: #05080b;
161
162
  border-radius: 6px;
162
163
  }
164
+
165
+ /* ── Past sessions (H3 read path / #222): the captured-session history + replay. ──────────────── */
166
+
167
+ .cockpit-past-header {
168
+ display: flex;
169
+ flex-wrap: wrap;
170
+ align-items: baseline;
171
+ justify-content: space-between;
172
+ gap: 8px;
173
+ margin-bottom: 8px;
174
+ }
175
+
176
+ .cockpit-past-title {
177
+ font-size: 13px;
178
+ margin: 0;
179
+ color: var(--cockpit-muted);
180
+ text-transform: uppercase;
181
+ letter-spacing: 0.04em;
182
+ }
183
+
184
+ .cockpit-past-summary {
185
+ color: var(--cockpit-muted);
186
+ font-size: 12px;
187
+ font-variant-numeric: tabular-nums;
188
+ }
189
+
190
+ .cockpit-past-table {
191
+ width: 100%;
192
+ border-collapse: collapse;
193
+ font-variant-numeric: tabular-nums;
194
+ }
195
+
196
+ .cockpit-past-replay {
197
+ background: none;
198
+ border: none;
199
+ color: var(--cockpit-text);
200
+ cursor: pointer;
201
+ font: inherit;
202
+ padding: 0;
203
+ text-align: left;
204
+ text-decoration: underline;
205
+ text-underline-offset: 2px;
206
+ }
207
+
208
+ .cockpit-past-replay:hover { color: #58a6ff; }
209
+
210
+ .cockpit-past-session[data-active="true"] {
211
+ background: rgba(88, 166, 255, 0.12);
212
+ }
213
+
214
+ .cockpit-past-status { color: var(--cockpit-muted); }
215
+ .cockpit-past-size { color: var(--cockpit-muted); }
216
+ .cockpit-past-captured { color: var(--cockpit-muted); font-size: 12px; }
217
+
218
+ .cockpit-past-empty {
219
+ color: var(--cockpit-muted);
220
+ padding: 8px 0;
221
+ }
222
+
223
+ /* Distinguish a live terminal from a replayed (static) past session at a glance. */
224
+ .cockpit-terminal[data-terminal-mode="replay"] {
225
+ border-color: #8957e5;
226
+ }
227
+ .cockpit-terminal[data-terminal-mode="replay"] .cockpit-panel-title {
228
+ color: #b392f0;
229
+ }
230
+ .cockpit-terminal[data-terminal-mode="live"] .cockpit-panel-title {
231
+ color: var(--cockpit-green);
232
+ }
@@ -21,6 +21,7 @@ import { Terminal } from "@xterm/xterm";
21
21
 
22
22
  const DEFAULT_REFRESH_MS = 2000;
23
23
  const DEFAULT_STALE_AFTER_MS = 15_000;
24
+ const DEFAULT_PAST_FETCH_TIMEOUT_MS = 15_000;
24
25
 
25
26
  function isPosInt(value) {
26
27
  return Number.isSafeInteger(value) && value > 0;
@@ -181,6 +182,113 @@ function renderSupply(host, doc, view, onDrill) {
181
182
  host.appendChild(root);
182
183
  }
183
184
 
185
+ // ── past-sessions projection + render (mirrors app/agentic/cockpit/transcript-view.ts + -render.ts) ──
186
+
187
+ function humanBytes(bytes) {
188
+ if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
189
+ if (bytes < 1024) return `${bytes} B`;
190
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
191
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
192
+ }
193
+
194
+ function humanDuration(ms) {
195
+ if (ms == null || !Number.isFinite(ms) || ms <= 0) return undefined;
196
+ const s = Math.round(ms / 1000);
197
+ if (s < 60) return `${s}s`;
198
+ const m = Math.round(s / 60);
199
+ if (m < 60) return `${m}m`;
200
+ const h = Math.round(m / 60);
201
+ if (h < 48) return `${h}h`;
202
+ return `${Math.round(h / 24)}d`;
203
+ }
204
+
205
+ function sessionLabel(t) {
206
+ const parts = [];
207
+ if (t.bpmnProcessId != null) parts.push(t.bpmnProcessId);
208
+ if (t.elementId != null) parts.push(t.elementId);
209
+ if (t.processInstanceKey != null) parts.push(`inst ${t.processInstanceKey}`);
210
+ if (t.planKey != null) parts.push(t.planKey);
211
+ if (parts.length > 0) return parts.join(" \u00b7 ");
212
+ if (t.jobKey != null) return `job ${t.jobKey}`;
213
+ return t.stream;
214
+ }
215
+
216
+ function transcriptsView(report) {
217
+ const sessions = (report.transcripts ?? [])
218
+ .map((t) => ({
219
+ stream: t.stream,
220
+ label: sessionLabel(t),
221
+ jobKey: t.jobKey,
222
+ status: t.status,
223
+ lifecycle: t.lifecycle,
224
+ size: humanBytes(t.byteLength),
225
+ byteLength: t.byteLength,
226
+ capturedAt: t.completedAt ?? t.createdAt,
227
+ }))
228
+ .sort((a, b) => {
229
+ const byTime = String(b.capturedAt).localeCompare(String(a.capturedAt));
230
+ return byTime !== 0 ? byTime : a.stream.localeCompare(b.stream);
231
+ });
232
+ return { sessions, count: sessions.length, retention: humanDuration(report.retentionMs) };
233
+ }
234
+
235
+ function sessionRow(doc, session, onReplay, activeStream) {
236
+ const row = el(doc, "tr", "cockpit-past-session");
237
+ row.setAttribute("data-stream", session.stream);
238
+ row.setAttribute("data-status", session.status);
239
+ if (session.jobKey != null) row.setAttribute("data-job-key", session.jobKey);
240
+ if (activeStream === session.stream) row.setAttribute("data-active", "true");
241
+ const nameCell = el(doc, "td", "cockpit-td cockpit-past-name");
242
+ const button = el(doc, "button", "cockpit-past-replay", session.label);
243
+ button.setAttribute("type", "button");
244
+ button.setAttribute("data-stream", session.stream);
245
+ if (onReplay) button.addEventListener("click", () => onReplay(session.stream));
246
+ nameCell.appendChild(button);
247
+ row.appendChild(nameCell);
248
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-past-status", session.status));
249
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-past-size", session.size));
250
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-past-captured", session.capturedAt));
251
+ return row;
252
+ }
253
+
254
+ function renderTranscripts(host, doc, view, onReplay, activeStream) {
255
+ host.replaceChildren();
256
+ const root = el(doc, "div", "cockpit-past");
257
+ root.setAttribute("data-session-count", String(view.count));
258
+ const header = el(doc, "header", "cockpit-past-header");
259
+ header.appendChild(el(doc, "h2", "cockpit-past-title", "Past sessions"));
260
+ const summary = el(doc, "span", "cockpit-past-summary", view.retention != null ? `${view.count} \u00b7 kept ${view.retention}` : `${view.count}`);
261
+ summary.setAttribute("data-summary", "past");
262
+ header.appendChild(summary);
263
+ root.appendChild(header);
264
+ if (view.count === 0) {
265
+ const empty = el(doc, "div", "cockpit-past-empty", "No captured sessions yet.");
266
+ empty.setAttribute("data-empty", "true");
267
+ root.appendChild(empty);
268
+ host.appendChild(root);
269
+ return;
270
+ }
271
+ const table = el(doc, "table", "cockpit-past-table");
272
+ const thead = el(doc, "thead", "cockpit-past-thead");
273
+ const head = el(doc, "tr", "cockpit-past-head");
274
+ for (const label of ["session", "status", "size", "captured"]) head.appendChild(el(doc, "th", "cockpit-th", label));
275
+ thead.appendChild(head);
276
+ table.appendChild(thead);
277
+ const tbody = el(doc, "tbody", "cockpit-past-tbody");
278
+ for (const session of view.sessions) tbody.appendChild(sessionRow(doc, session, onReplay, activeStream));
279
+ table.appendChild(tbody);
280
+ root.appendChild(table);
281
+ host.appendChild(root);
282
+ }
283
+
284
+ /** Feed a fetched transcript's stored chunks through a resume-from-offset TerminalSession (static playback). */
285
+ function replayTranscript(session, data) {
286
+ session.handle({ op: "subscribed", stream: data.stream, gap: data.gap, nextOffset: data.nextOffset });
287
+ for (const entry of data.entries ?? []) {
288
+ session.handle({ stream: data.stream, offset: entry.offset, chunk: entry.chunk });
289
+ }
290
+ }
291
+
184
292
  // ── boot orchestration (mirrors app/agentic/cockpit/supply-boot.ts) ────────────────────────────
185
293
 
186
294
  /** An xterm.js-backed terminal sink mounted into `host`. */
@@ -219,6 +327,10 @@ function relaySocketFactory(url) {
219
327
  * @param {number} [opts.refreshMs] — poll interval (default 2000).
220
328
  * @param {number} [opts.staleAfterMs] — a worker is rendered "stale" once its last heartbeat is at
221
329
  * least this many ms old (default 15000).
330
+ * @param {number} [opts.pastFetchTimeoutMs] — upper bound (ms) on a single past-sessions transcripts
331
+ * fetch; the fetch is aborted past this so a hung endpoint can't wedge the past panel (default 15000).
332
+ * @param {string} [opts.transcriptsUrl] — the captured-session list endpoint (default
333
+ * /app/api/agentic/transcripts) backing the always-on "past sessions" history + replay.
222
334
  * @returns a handle with `.dispose()`.
223
335
  */
224
336
  export function mountCockpit(host, opts = {}) {
@@ -229,6 +341,7 @@ export function mountCockpit(host, opts = {}) {
229
341
  }
230
342
  const doc = document;
231
343
  const reportUrl = opts.reportUrl ?? "/app/api/agentic/supply";
344
+ const transcriptsUrl = opts.transcriptsUrl ?? "/app/api/agentic/transcripts";
232
345
  const hookSecret = opts.hookSecret;
233
346
  const relayUrl = opts.relayUrl ?? defaultRelayUrl(opts.relayToken, opts.relayCapability);
234
347
  const refreshMs = opts.refreshMs ?? DEFAULT_REFRESH_MS;
@@ -239,20 +352,40 @@ export function mountCockpit(host, opts = {}) {
239
352
  throw new RangeError(`mountCockpit(opts.refreshMs): must be a positive safe integer, got ${refreshMs}.`);
240
353
  }
241
354
  const staleAfterMs = opts.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
355
+ // Upper bound on a single "past sessions" transcripts fetch. refreshPast() is single-flight, so a
356
+ // fetch that HANGS (never settles) would otherwise leave `pastRefreshing` stuck true forever and
357
+ // permanently disable the past panel; a bounded (aborting) fetch clears the flag so the next poll retries.
358
+ const pastFetchTimeoutMs = opts.pastFetchTimeoutMs ?? DEFAULT_PAST_FETCH_TIMEOUT_MS;
359
+ if (!isPosInt(pastFetchTimeoutMs)) {
360
+ throw new RangeError(
361
+ `mountCockpit(opts.pastFetchTimeoutMs): must be a positive safe integer, got ${pastFetchTimeoutMs}.`,
362
+ );
363
+ }
242
364
  const connectRelay = relaySocketFactory(relayUrl);
243
365
  const onError = (err) => console.error("[cockpit]", err);
244
366
 
245
- // Stable skeleton: a volatile list region the poll re-renders + a PERSISTENT terminal region a
246
- // refresh never touches (so a drilled-in terminal survives a list refresh).
367
+ const jsonHeaders = () => {
368
+ const headers = { accept: "application/json" };
369
+ if (hookSecret) headers["x-hook-secret"] = hookSecret;
370
+ return headers;
371
+ };
372
+
373
+ // Stable skeleton: a volatile supply-list region + a volatile "past sessions" region the poll
374
+ // re-renders, and a PERSISTENT terminal region a refresh never touches (so a drilled-in/replayed
375
+ // terminal survives a list refresh). The terminal panel title distinguishes live vs replayed.
247
376
  host.replaceChildren();
248
377
  const shell = el(doc, "div", "cockpit-shell");
249
378
  const listRegion = el(doc, "div", "cockpit-supply-region");
379
+ const pastRegion = el(doc, "div", "cockpit-past-region");
250
380
  const terminalPanel = el(doc, "section", "cockpit-terminal");
251
- terminalPanel.appendChild(el(doc, "h2", "cockpit-panel-title", "Worker terminal"));
381
+ terminalPanel.setAttribute("data-terminal-mode", "idle");
382
+ const terminalTitle = el(doc, "h2", "cockpit-panel-title", "Worker terminal");
383
+ terminalPanel.appendChild(terminalTitle);
252
384
  const terminalHost = el(doc, "div", "cockpit-terminal-host");
253
385
  terminalHost.setAttribute("data-terminal", "host");
254
386
  terminalPanel.appendChild(terminalHost);
255
387
  shell.appendChild(listRegion);
388
+ shell.appendChild(pastRegion);
256
389
  shell.appendChild(terminalPanel);
257
390
  host.appendChild(shell);
258
391
 
@@ -262,13 +395,36 @@ export function mountCockpit(host, opts = {}) {
262
395
  let generation = 0;
263
396
  let drill; // { stream, client }
264
397
  let terminal; // the current xterm sink
398
+ let mode; // "live" | "replay" | undefined
399
+ let shownStream;
400
+ // Bumped by every drillInto()/replayInto()/dispose() that claims the terminal region, so a slow
401
+ // replay fetch that resolves after a newer selection drops its result instead of clobbering it.
402
+ let opToken = 0;
403
+ // True while a refreshPast() fetch is outstanding, so the supply poll never stacks past-fetches
404
+ // against a slow/hung transcripts endpoint.
405
+ let pastRefreshing = false;
265
406
 
266
- function drillInto(stream) {
267
- if (disposed || drill?.stream === stream) return;
407
+ function setMode(next, stream) {
408
+ mode = next;
409
+ shownStream = stream;
410
+ terminalPanel.setAttribute("data-terminal-mode", next ?? "idle");
411
+ if (next === "live") terminalTitle.textContent = "Worker terminal — live";
412
+ else if (next === "replay") terminalTitle.textContent = "Worker terminal — replay (past session)";
413
+ else terminalTitle.textContent = "Worker terminal";
414
+ }
415
+
416
+ function teardownTerminal() {
268
417
  drill?.client.close();
269
418
  drill = undefined;
270
419
  terminal?.dispose?.();
271
420
  terminal = undefined;
421
+ }
422
+
423
+ function drillInto(stream) {
424
+ if (disposed || (mode === "live" && drill?.stream === stream)) return;
425
+ // Claim the terminal region: bump the op token so an in-flight replay drops its stale result.
426
+ opToken++;
427
+ teardownTerminal();
272
428
  try {
273
429
  terminalHost.replaceChildren();
274
430
  const sink = xtermSink(terminalHost);
@@ -283,18 +439,102 @@ export function mountCockpit(host, opts = {}) {
283
439
  session = new TerminalSession({ stream, sink, send: (message) => client.sendRelay(message) });
284
440
  client.open();
285
441
  drill = { stream, client };
442
+ setMode("live", stream);
443
+ } catch (err) {
444
+ // The new terminal failed to build after the prior one was torn down: reset the region to idle
445
+ // (and drop any partially-built terminal) so the UI never shows a stale "live"/"replay"
446
+ // indicator with nothing behind it — symmetric with replayInto(), which clears mode up-front.
447
+ teardownTerminal();
448
+ setMode(undefined, undefined);
449
+ onError(err);
450
+ }
451
+ }
452
+
453
+ async function replayInto(stream) {
454
+ if (disposed) return;
455
+ // Claim the terminal region under a fresh op token, captured for the post-fetch re-check below.
456
+ const token = ++opToken;
457
+ // Drop any live drill + prior terminal before fetching so replay never overlaps a live stream.
458
+ teardownTerminal();
459
+ setMode(undefined, undefined);
460
+ let data;
461
+ try {
462
+ // Bound the fetch: a transcript endpoint that never responds would otherwise leave replay() pending
463
+ // forever with an in-flight request and the terminal wedged out of live mode. Abort after
464
+ // pastFetchTimeoutMs so the fetch always settles (here, rejects) and this catch leaves mode idle.
465
+ const controller = new AbortController();
466
+ const abortTimer = setTimeout(() => controller.abort(), pastFetchTimeoutMs);
467
+ abortTimer.unref?.();
468
+ let res;
469
+ try {
470
+ res = await fetch(`${transcriptsUrl}/${encodeURIComponent(stream)}`, { headers: jsonHeaders(), signal: controller.signal });
471
+ } finally {
472
+ clearTimeout(abortTimer);
473
+ }
474
+ if (!res.ok) throw new Error(`transcript fetch failed: ${res.status}`);
475
+ data = await res.json();
476
+ } catch (err) {
477
+ onError(err);
478
+ return;
479
+ }
480
+ // A newer drill/replay (or dispose) claimed the terminal while this fetch was outstanding — drop
481
+ // the stale result rather than overwrite the newer selection with an out-of-date replay.
482
+ if (disposed || token !== opToken) return;
483
+ try {
484
+ terminalHost.replaceChildren();
485
+ const sink = xtermSink(terminalHost);
486
+ terminal = sink;
487
+ const session = new TerminalSession({ stream, sink, send: () => {}, from: data.from ?? 0 });
488
+ replayTranscript(session, data);
489
+ setMode("replay", stream);
490
+ void refreshPast();
286
491
  } catch (err) {
287
492
  onError(err);
288
493
  }
289
494
  }
290
495
 
496
+ async function refreshPast() {
497
+ // Single-flight: while one past-fetch is outstanding (including a hung one), skip starting another
498
+ // so the supply poll can't stack pending fetches against a slow/unresponsive transcripts endpoint.
499
+ if (pastRefreshing) return;
500
+ pastRefreshing = true;
501
+ try {
502
+ let report;
503
+ try {
504
+ // Bound the fetch: refreshPast() is single-flight, so a transcripts endpoint that never responds
505
+ // would otherwise wedge `pastRefreshing` true forever. Abort after pastFetchTimeoutMs so the fetch
506
+ // always settles (here, rejects), the finally clears the flag, and the next poll can retry.
507
+ const controller = new AbortController();
508
+ const abortTimer = setTimeout(() => controller.abort(), pastFetchTimeoutMs);
509
+ abortTimer.unref?.();
510
+ let res;
511
+ try {
512
+ res = await fetch(transcriptsUrl, { headers: jsonHeaders(), signal: controller.signal });
513
+ } finally {
514
+ clearTimeout(abortTimer);
515
+ }
516
+ if (!res.ok) throw new Error(`transcripts fetch failed: ${res.status}`);
517
+ report = await res.json();
518
+ } catch (err) {
519
+ onError(err);
520
+ return;
521
+ }
522
+ if (disposed) return;
523
+ try {
524
+ renderTranscripts(pastRegion, doc, transcriptsView(report), replayInto, mode === "replay" ? shownStream : undefined);
525
+ } catch (err) {
526
+ onError(err);
527
+ }
528
+ } finally {
529
+ pastRefreshing = false;
530
+ }
531
+ }
532
+
291
533
  async function refresh() {
292
534
  if (disposed) return;
293
535
  let report;
294
536
  try {
295
- const headers = { accept: "application/json" };
296
- if (hookSecret) headers["x-hook-secret"] = hookSecret;
297
- const res = await fetch(reportUrl, { headers });
537
+ const res = await fetch(reportUrl, { headers: jsonHeaders() });
298
538
  if (!res.ok) throw new Error(`supply fetch failed: ${res.status}`);
299
539
  report = await res.json();
300
540
  } catch (err) {
@@ -307,6 +547,8 @@ export function mountCockpit(host, opts = {}) {
307
547
  } catch (err) {
308
548
  onError(err);
309
549
  }
550
+ // Fire-and-forget: a hung transcripts endpoint must never stall the supply poll's next tick.
551
+ void refreshPast();
310
552
  }
311
553
 
312
554
  function tick(gen) {
@@ -332,15 +574,14 @@ export function mountCockpit(host, opts = {}) {
332
574
  function dispose() {
333
575
  if (disposed) return;
334
576
  disposed = true;
577
+ opToken++;
335
578
  stop();
336
- drill?.client.close();
337
- drill = undefined;
338
- terminal?.dispose?.();
339
- terminal = undefined;
579
+ teardownTerminal();
580
+ setMode(undefined, undefined);
340
581
  }
341
582
 
342
583
  start();
343
- return { start, stop, dispose, refresh, drill: drillInto };
584
+ return { start, stop, dispose, refresh, drill: drillInto, replay: replayInto };
344
585
  }
345
586
 
346
587
  /**
@@ -30,7 +30,7 @@
30
30
  "type": "text",
31
31
  "id": "intro",
32
32
  "props": {
33
- "text": "The live worker/supply view: every connected worker grouped by leaf token, with family, host, current jobs, and liveness — sourced from the agentic presence registry. Drill into a worker to stream its terminal live over the relay; the terminal stays mounted across a list refresh and re-attaches (resume-from-offset) on reconnect. The same view renders embedded here (App View) and standalone on a phone. (The demand×supply matrix, missing-agent-type lights, and the diversity SLO are the enrolment epic's board — not shown here.)",
33
+ "text": "The live worker/supply view: every connected worker grouped by leaf token, with family, host, current jobs, and liveness — sourced from the agentic presence registry. Drill into a worker to stream its terminal live over the relay; the terminal stays mounted across a list refresh and re-attaches (resume-from-offset) on reconnect. Beside it, a \"past sessions\" history lists the durable transcripts agents flushed on completion — select one to replay its captured terminal statically into the same panel (the title distinguishes live vs replayed). The same view renders embedded here (App View) and standalone on a phone. (The demand×supply matrix, missing-agent-type lights, and the diversity SLO are the enrolment epic's board — not shown here.)",
34
34
  "variant": "sub"
35
35
  }
36
36
  },
@@ -48,20 +48,39 @@ Because several agents may run on the same host at once:
48
48
 
49
49
  ## What to do in a round
50
50
 
51
- 1. **Read the latest review.** Fetch the newest Copilot review + its inline
52
- comments on the PR (`gh pr view`, `gh api .../pulls/{n}/reviews`, `.../comments`).
51
+ 1. **Read the latest review AND every still-open thread.** Fetch the newest Copilot
52
+ review + its inline comments on the PR (`gh pr view`, `gh api .../pulls/{n}/reviews`,
53
+ `.../comments`). Then **also enumerate every UNRESOLVED review thread on the PR**,
54
+ not just the latest review's comments — findings accumulate as durable threads, so
55
+ an earlier round's comment stays open until someone resolves it, and the newest
56
+ review will **not** re-list it. Treat the full set of open threads as your backlog
57
+ for this round, not only the latest review:
58
+
59
+ ```sh
60
+ # Every open thread across ALL reviews (this is your real backlog, oldest included):
61
+ # `pageInfo{hasNextPage endCursor}` surfaces truncation AND gives you the cursor to page with: if
62
+ # `hasNextPage` is `true` there are >100 threads this page can't see — re-run passing that
63
+ # `endCursor` back as `$after` until `hasNextPage` is `false`; never treat one page as "every"
64
+ # thread. Omit `-F after=…` (leaving `$after` null) for the first page.
65
+ gh api graphql -f query='query($o:String!,$r:String!,$n:Int!,$after:String){repository(owner:$o,name:$r){
66
+ pullRequest(number:$n){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved path line
67
+ comments(first:100){nodes{databaseId author{login} body}}}}}}}' -F o=OWNER -F r=REPO -F n=PR # add -F after=END_CURSOR to page
68
+ ```
69
+
53
70
  Also read Copilot's **suppressed / low-confidence** advisories — the collapsed
54
71
  "low confidence" list Copilot folds into the **review body** (`.../reviews`
55
72
  `body`). These are NOT in the default inline-comment API set, so a plain
56
73
  `.../comments` read misses them; scan the review body for them explicitly.
57
74
  If `answer` is present, treat it as the human's decision on the escalation you
58
75
  raised last round and act on it first.
59
- 2. **Triage each comment** into: *fix* (correct, worth doing), *nitpick* (apply
76
+ 2. **Triage each item** into: *fix* (correct, worth doing), *nitpick* (apply
60
77
  silently), *needs human input* (design/product/tradeoff you can't decide), or
61
- *push back* (wrong / false positive — reply with evidence, make no change).
62
- Triage the suppressed / low-confidence advisories the **same** way but do not
63
- treat "suppressed" as either automatically actionable or automatically ignorable:
64
- if one is a **cheap, correct** robustness/correctness win, just do it (a
78
+ *push back* (wrong / false positive — reply with evidence, make no change). Triage
79
+ **every open thread from step 1**, including ones raised in earlier rounds that the
80
+ latest review did not repeat do not skip a thread just because it is not in the
81
+ newest review. Triage the suppressed / low-confidence advisories the **same** way —
82
+ but do not treat "suppressed" as either automatically actionable or automatically
83
+ ignorable: if one is a **cheap, correct** robustness/correctness win, just do it (a
65
84
  *nitpick*); otherwise **decline it explicitly with a one-line rationale in your
66
85
  `summary`** (e.g. "declined suppressed advisory X — input already validated
67
86
  upstream at Y"). Never silently drop one.
@@ -93,6 +112,26 @@ Because several agents may run on the same host at once:
93
112
  # Resolve the thread whose databaseId matched the comment you handled:
94
113
  gh api graphql -f query='mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}' -F id=THREAD_NODE_ID
95
114
  ```
115
+ 5a. **Acknowledge every suppressed / low-confidence advisory with a resolvable ack
116
+ thread.** Suppressed advisories live in the review **body**, not as inline
117
+ comment threads, so they cannot be resolved and Copilot **re-lists them every
118
+ round**. The process now *deterministically blocks convergence* until each one
119
+ carries a **resolved** acknowledgement, so a decision you only wrote into your
120
+ `summary` is invisible to the gate. For each suppressed advisory you applied or
121
+ declined (step 2), post a **new review comment thread** whose body contains the
122
+ verbatim marker **`nano-ack: <path>:<line>`** — copied exactly from Copilot's
123
+ bold `**<path>:<line>**` header for that advisory — then **resolve** that thread.
124
+ The gate matches on the marker **text**, so the thread may sit on any valid diff
125
+ line; only the exact `path:line` string must match. Example:
126
+
127
+ ```sh
128
+ # Post the ack thread (pick any changed line in the diff for path/line). Use the PR's real HEAD
129
+ # SHA as commit_id — `git rev-parse HEAD` can drift from the PR head; ask GitHub:
130
+ CID=$(gh api repos/OWNER/REPO/pulls/PR --jq .head.sha)
131
+ gh api repos/OWNER/REPO/pulls/PR/comments -f commit_id="$CID" -f path=PATH -F line=LINE -f side=RIGHT \
132
+ -f body='Applied. nano-ack: <path>:<line>' # or: 'Declined, false positive — <reason>. nano-ack: <path>:<line>'
133
+ # Then resolve it exactly like any other thread (map its databaseId -> thread node id -> resolveReviewThread).
134
+ ```
96
135
  6. **Do NOT request, re-request, or remove the reviewer yourself.** Keeping
97
136
  Copilot attached is the **process's** job: a deterministic poller ensures a
98
137
  Copilot review is requested (idempotently) whenever this PR is waiting, and it
@@ -118,11 +157,18 @@ Consider the PR **converged** when the latest review has no actionable comment:
118
157
  - every new comment is a nitpick you already handled or intentionally declined,
119
158
  **or**
120
159
  - the only remaining items are suppressed / low-confidence advisories you have
121
- triaged and either applied or declined-with-rationale (a suppressed advisory
122
- you have recorded a decision on does **not** block convergence), **or**
160
+ triaged and either applied or declined-with-rationale **and acknowledged with a
161
+ resolved `nano-ack:` thread** (step 5a) an advisory you have merely decided on
162
+ in prose still **blocks** convergence until its ack thread is resolved, **or**
123
163
  - Copilot is looping — reiterating a point you already addressed or pushed back
124
164
  on (two rounds of the same substantive point = converged).
125
165
 
166
+ Returning `converged` is necessary but **not sufficient**: after you return it the
167
+ process runs a deterministic gate that re-checks GitHub and will **block** convergence
168
+ (routing to a human) while **any** review thread is unresolved or **any** suppressed
169
+ advisory lacks a resolved `nano-ack:` thread. Resolve every thread and acknowledge
170
+ every advisory (steps 5 + 5a) *before* you converge, or the PR bounces to a human.
171
+
126
172
  ### No review has landed yet — return `waiting`, do NOT escalate
127
173
 
128
174
  A PR is **not** converged merely because there are zero reviews and zero