omp-conductor 0.18.0 → 0.18.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 (61) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +60 -10
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +29 -0
  6. package/src/admission.ts +204 -75
  7. package/src/ask.ts +268 -7
  8. package/src/board.ts +17 -3
  9. package/src/briefs/orchestrator.md +42 -14
  10. package/src/briefs/to-spec.md +84 -0
  11. package/src/briefs/worker.md +2 -1
  12. package/src/cli.ts +2 -0
  13. package/src/command-help.ts +11 -0
  14. package/src/command-manifest.ts +22 -0
  15. package/src/commands/context.ts +1 -0
  16. package/src/commands/drain.ts +176 -0
  17. package/src/commands/extend.ts +6 -10
  18. package/src/commands/status.ts +5 -1
  19. package/src/commands/watch.ts +50 -2
  20. package/src/commands/worker.ts +9 -10
  21. package/src/config-schema.ts +24 -0
  22. package/src/config.ts +42 -1
  23. package/src/daemon.ts +965 -36
  24. package/src/dashboard/app.js +4 -1
  25. package/src/dashboard/server.ts +5 -2
  26. package/src/decisions.ts +235 -17
  27. package/src/diff-flags.ts +75 -1
  28. package/src/doctor.ts +52 -0
  29. package/src/escalate.ts +9 -3
  30. package/src/failure-class.ts +28 -2
  31. package/src/fleet.ts +146 -22
  32. package/src/gitops.ts +188 -81
  33. package/src/graph-health.ts +35 -1
  34. package/src/graph.ts +66 -1
  35. package/src/harness-loader.ts +59 -0
  36. package/src/host.ts +567 -2
  37. package/src/lifecycle.ts +122 -1
  38. package/src/omp.ts +227 -20
  39. package/src/orchestrator-tick.ts +1386 -15
  40. package/src/orchestrator.ts +12 -0
  41. package/src/privileged.ts +1 -4
  42. package/src/release-policy.ts +503 -9
  43. package/src/session-host.ts +99 -5
  44. package/src/settlement.ts +69 -17
  45. package/src/setup-host.ts +1205 -6
  46. package/src/setup-install.ts +28 -0
  47. package/src/setup-wizard.ts +13 -2
  48. package/src/setup.ts +29 -13
  49. package/src/shell.ts +15 -0
  50. package/src/status-render.ts +78 -11
  51. package/src/store.ts +443 -42
  52. package/src/to-spec.ts +387 -0
  53. package/src/tracker/github.ts +104 -14
  54. package/src/types.ts +343 -13
  55. package/src/upgrade-verify.ts +209 -2
  56. package/src/upgrade.ts +175 -1
  57. package/src/verbs/protocol.ts +39 -0
  58. package/src/verbs/server.ts +730 -56
  59. package/src/verbs/socket.ts +24 -5
  60. package/src/worker.ts +25 -2
  61. package/src/worktree.ts +29 -12
@@ -52,6 +52,8 @@ import {
52
52
  agentRenameVerdict,
53
53
  checkEscalation,
54
54
  DEFAULT_AGENT_RENAME_DEPS,
55
+ defaultIdentityProbes,
56
+ defaultServiceRuntime,
55
57
  planHostRuntime,
56
58
  totalConfiguredWorkers,
57
59
  writeHostRuntime,
@@ -61,6 +63,7 @@ import {
61
63
  type AgentRenameDeps,
62
64
  type EscalationDeps,
63
65
  type ServiceRuntime,
66
+ type WorkerIdentityProbes,
64
67
  } from "./setup-host.ts";
65
68
  import type { WizardUi } from "./wizard-ui.ts";
66
69
  import type { Caps, ProjectConfig, Store } from "./types.ts";
@@ -81,6 +84,14 @@ export interface InstallDeps {
81
84
  escalation?: EscalationDeps;
82
85
  /** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
83
86
  platform?: () => string;
87
+ /**
88
+ * The worker identity plan's probes (#798). `undefined` means the real
89
+ * host's read-only facts — production: every `setup host` installs and
90
+ * grants the dedicated worker account. `null` disables the identity plan
91
+ * entirely (the historical step list, for tests pinning it), and an object
92
+ * is used as given (tests pinning a specific host state).
93
+ */
94
+ workerIdentityProbes?: WorkerIdentityProbes | null;
84
95
  unitDir?: string;
85
96
  /**
86
97
  * Where the recovery playbook installs. Injectable so the idempotency gate
@@ -188,6 +199,13 @@ export async function runHostInstall(
188
199
  // happens only after consent (#510), so the prompt's "Nothing has been run
189
200
  // yet" is true of the staged tree when it is printed, and a declined run
190
201
  // leaves every staged file byte-identical.
202
+ // The worker identity plan rides the same consent (#798): production probes
203
+ // the real host (account present? grants current?); a test may pass its own
204
+ // probes, or `null` to plan the historical step list only.
205
+ const identityProbes: WorkerIdentityProbes | null =
206
+ deps.workerIdentityProbes === null
207
+ ? null
208
+ : deps.workerIdentityProbes ?? defaultIdentityProbes(deps.runtime ?? defaultServiceRuntime(telegramStateDir));
191
209
  const plan = planHostRuntime(
192
210
  project,
193
211
  caps,
@@ -197,6 +215,7 @@ export async function runHostInstall(
197
215
  unitDir,
198
216
  deps.recoverScriptInstallPath ?? RECOVER_SCRIPT_INSTALL_PATH,
199
217
  deps.multiProject ?? hostMultiProject(),
218
+ identityProbes,
200
219
  );
201
220
  // No pane-shell key is planned (unusable login shell, unparseable config):
202
221
  // the operator hears why before the consent prompt, not after an install
@@ -298,6 +317,15 @@ export async function runHostInstall(
298
317
  ]),
299
318
  ]),
300
319
  "The unit runs as the account that staged it; nothing here changes that.",
320
+ ...(plan.workerIdentity === undefined
321
+ ? []
322
+ : [
323
+ `Establishes the dedicated worker identity ${plan.workerIdentity.account}: every worker session runs under it, ` +
324
+ "never under the daemon's account, and it is granted search/read access to exactly the fleet paths a worker needs.",
325
+ ...(plan.workerIdentity.current
326
+ ? ["The worker identity is already in place — nothing to change."]
327
+ : plan.workerIdentity.pending),
328
+ ]),
301
329
  "",
302
330
  liveWorkers === 0
303
331
  ? "No workers are live — nothing to drain before the restart."
@@ -979,6 +979,7 @@ function seedFromDiscovery(seed: SetupAnswers, found: DiscoveredFacts): SetupAns
979
979
  inProgress: labels.get(seed.stateLabels.inProgress.toLowerCase()) ?? seed.stateLabels.inProgress,
980
980
  blocked: labels.get(seed.stateLabels.blocked.toLowerCase()) ?? seed.stateLabels.blocked,
981
981
  failed: labels.get(seed.stateLabels.failed.toLowerCase()) ?? seed.stateLabels.failed,
982
+ backlog: labels.get(seed.stateLabels.backlog.toLowerCase()) ?? seed.stateLabels.backlog,
982
983
  },
983
984
  targetRepos,
984
985
  policy: {
@@ -1094,19 +1095,28 @@ const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
1094
1095
 
1095
1096
  // One confirm instead of three prompts: the namespaced defaults are right for
1096
1097
  // almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
1097
- // its reputation.
1098
+ // its reputation. The park label is in the same confirm, with its ownership
1099
+ // stated — it is not conductor-written, but it has to exist for the operator
1100
+ // to have the gesture (#507).
1098
1101
  const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
1099
1102
  const customiseStates = await askYesNo(
1100
1103
  ui,
1101
1104
  "customise-state-labels",
1102
1105
  "State labels",
1103
1106
  `The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
1104
- `"${stateLabels.failed}" so the tracker alone shows live state. Rename them?`,
1107
+ `"${stateLabels.failed}" so the tracker alone shows live state; "${stateLabels.backlog}" is ` +
1108
+ `yours — the conductor never writes it, but an issue carrying it is never claimed. Rename them?`,
1105
1109
  );
1106
1110
  if (customiseStates) {
1107
1111
  stateLabels.inProgress = await ask(ui, "state-label-in-progress", "Label for a run in progress", stateLabels.inProgress);
1108
1112
  stateLabels.blocked = await ask(ui, "state-label-blocked", "Label for a run parked on a human", stateLabels.blocked);
1109
1113
  stateLabels.failed = await ask(ui, "state-label-failed", "Label for a run that gave up", stateLabels.failed);
1114
+ stateLabels.backlog = await ask(
1115
+ ui,
1116
+ "state-label-backlog",
1117
+ "Label the operator applies to park an issue (the conductor never writes it)",
1118
+ stateLabels.backlog,
1119
+ );
1110
1120
  }
1111
1121
 
1112
1122
  const routingLabelPrefix = await ask(
@@ -2516,6 +2526,7 @@ function captureInventory(
2516
2526
  };
2517
2527
  captureIfWritten(plan.runtime.service);
2518
2528
  captureIfWritten(plan.runtime.herdrUnit);
2529
+ captureIfWritten(plan.runtime.harnessMount);
2519
2530
  captureIfWritten(plan.runtime.herdrConfig);
2520
2531
  captureIfWritten(plan.runtime.herdrEnv);
2521
2532
  captureIfWritten(plan.runtime.recoverUnit);
package/src/setup.ts CHANGED
@@ -39,7 +39,6 @@ import {
39
39
  import {
40
40
  clonePolicy,
41
41
  configPath,
42
- DEFAULT_STATE_LABELS,
43
42
  defaultMirrorRoot,
44
43
  defaultWorkspaceRoot,
45
44
  resolveArmProof,
@@ -144,7 +143,7 @@ export interface SetupAnswers {
144
143
  /** Tracker repo as `owner/repo` — the only spelling `gh` takes without a host. */
145
144
  trackerRepo: string;
146
145
  queueLabel: string;
147
- stateLabels: { inProgress: string; blocked: string; failed: string };
146
+ stateLabels: { inProgress: string; blocked: string; failed: string; backlog: string };
148
147
  routingLabelPrefix: string;
149
148
  targetRepos: {
150
149
  name: string;
@@ -318,7 +317,12 @@ export interface LabelPlan {
318
317
  * cannot drift apart: one spelling of "ready-for-agent" in the package. */
319
318
  export const SETUP_DEFAULTS = {
320
319
  queueLabel: "ready-for-agent",
321
- stateLabels: { inProgress: "agent:in-progress", blocked: "agent:blocked", failed: "agent:failed" },
320
+ stateLabels: {
321
+ inProgress: "agent:in-progress",
322
+ blocked: "agent:blocked",
323
+ failed: "agent:failed",
324
+ backlog: "backlog",
325
+ },
322
326
  routingLabelPrefix: "repo:",
323
327
  defaultBranch: "main",
324
328
  /** Both authorities start with the human; the wizard asks to move each one. */
@@ -578,13 +582,15 @@ const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md");
578
582
  const REQUIRED_SCOPES = ["repo", "project"] as const;
579
583
 
580
584
  /** GitHub's own palette, so the tracker reads at a glance: green means queued,
581
- * blue means moving, amber means waiting on you, red means it gave up, and
582
- * purple is routing — the operator's input, the one label the loop never writes. */
585
+ * blue means moving, amber means waiting on you, red means it gave up, grey
586
+ * means the operator parked it, and purple means routing — the operator's
587
+ * input, the label the loop never writes. */
583
588
  const LABEL_COLOURS = {
584
589
  queue: "0e8a16",
585
590
  inProgress: "1d76db",
586
591
  blocked: "fbca04",
587
592
  failed: "b60205",
593
+ backlog: "8b949e",
588
594
  routing: "6f42c1",
589
595
  } as const;
590
596
 
@@ -668,14 +674,15 @@ export async function checkTokenScopes(): Promise<ScopeCheck> {
668
674
 
669
675
  /**
670
676
  * Every label setup wants on the tracker, before existence is known: the queue
671
- * and state labels the loop reads and writes, plus one routing label per routed
672
- * repo. Pure — the prefix and the repo keys are already answered at this point
673
- * in the interview — which is what lets a test pin the whole set without `gh`.
677
+ * and state labels the loop reads and writes, the operator's park label (which
678
+ * the loop must never write), plus one routing label per routed repo. Pure —
679
+ * the prefix and the repo keys are already answered at this point in the
680
+ * interview — which is what lets a test pin the whole set without `gh`.
674
681
  *
675
- * Routing labels are provisioned, never applied: `route()` requires exactly one
676
- * `<prefix><repo>` label per issue and treats zero or two as unroutable, so a
677
- * tracker without them can queue nothing. Creating them queues nothing either —
678
- * applying one, with the queue label, stays the operator's sign-off.
682
+ * The park label is provisioned like the rest but owned the other way round:
683
+ * it exists so the operator has a gesture, and the conductor never applies or
684
+ * removes it (#507). Creating it queues nothing either — applying one, with
685
+ * the queue label, stays the operator's sign-off.
679
686
  */
680
687
  export function wantedLabels(a: SetupAnswers): Omit<LabelPlan, "exists">[] {
681
688
  return [
@@ -699,6 +706,11 @@ export function wantedLabels(a: SetupAnswers): Omit<LabelPlan, "exists">[] {
699
706
  colour: LABEL_COLOURS.failed,
700
707
  description: "The conductor gave up on this issue after its retry budget",
701
708
  },
709
+ {
710
+ name: a.stateLabels.backlog,
711
+ colour: LABEL_COLOURS.backlog,
712
+ description: "Parked by the operator — never claimed, only the operator may set or clear it",
713
+ },
702
714
  ...a.targetRepos.map((r) => ({
703
715
  name: `${a.routingLabelPrefix}${r.name}`,
704
716
  colour: LABEL_COLOURS.routing,
@@ -973,7 +985,11 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
973
985
  name: a.projectName,
974
986
  tracker: { kind: "github", repo: a.trackerRepo },
975
987
  queueLabel: a.queueLabel,
976
- stateLabels: { ...a.stateLabels, backlog: DEFAULT_STATE_LABELS.backlog },
988
+ // The park label is now an answer like the other three: the wizard offers
989
+ // it and every answers constructor carries it, so writing the answers as
990
+ // they stand is the single source — slice 1's hardcoded default bridge is
991
+ // gone (#507 slice 2).
992
+ stateLabels: { ...a.stateLabels },
977
993
  routing: { labelPrefix: a.routingLabelPrefix, repos },
978
994
  caps,
979
995
  ...(a.workerModel !== undefined && a.workerModel.trim().length > 0
package/src/shell.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * One CLI argument rendered exactly as an operator would type it, for surfaces
3
+ * that print executable commands.
4
+ *
5
+ * A command a surface prints is a command the reader copies, so an argument
6
+ * that could mean something else is a defect: a project named `odd $fleet`
7
+ * must not render `--project odd $fleet` (two arguments, and a `$` that the
8
+ * shell would read). Safe characters pass through unquoted; anything else is
9
+ * wrapped in single quotes, folding an embedded quote with the standard
10
+ * `'\''` — the same convention the privileged-step renderer applies to its
11
+ * printed `sudo …` lines (#810).
12
+ */
13
+ export function shellQuote(value: string): string {
14
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
15
+ }
@@ -24,7 +24,7 @@ import type { CodeGraphHealth } from "./graph-health.ts";
24
24
  import { formatDigestBacklog, formatOpenReports } from "./reports.ts";
25
25
  import { formatDownDuration, formatOrchestratorDown } from "./orchestrator-down.ts";
26
26
  import { planUsageLine } from "./usage.ts";
27
- import { SYSTEMD_UNIT, type UnitOwnership } from "./lifecycle.ts";
27
+ import { HEALTH_TIMEOUT_MS, SYSTEMD_UNIT, type UnitOwnership } from "./lifecycle.ts";
28
28
  import type { WorkerPausePhase } from "./worker.ts";
29
29
  import { formatRss, rssBytesFromHealthz } from "./host.ts";
30
30
  import {
@@ -90,6 +90,14 @@ export function formatCodeGraphHealth(graph: CodeGraphHealth, now = Date.now()):
90
90
  `code graph ${graph.status} ${indexed}/${graph.repos.length} repos indexed`,
91
91
  ` indexer ${graph.prerequisites.indexer}`,
92
92
  ` MCP mount ${graph.prerequisites.mcpMount}`,
93
+ // The runtime half of the mount finding (#726): what dispatched sessions
94
+ // actually held in their registries. Rendered only when a run recorded
95
+ // it — "observed" is never claimed without evidence.
96
+ ...(graph.session.recorded === 0
97
+ ? []
98
+ : [
99
+ ` session observed in ${graph.session.recorded} run(s): present ${graph.session.present} / absent ${graph.session.absent}`,
100
+ ]),
93
101
  ` timer ${graph.timer.enabled} / ${graph.timer.active}`,
94
102
  ` refresh ${refresh}`,
95
103
  ...graph.reasons.map((reason) => ` - ${reason}`),
@@ -108,6 +116,7 @@ export type DaemonProjectHealth =
108
116
  | { kind: "stopped" }
109
117
  | { kind: "ok" }
110
118
  | { kind: "unreachable" }
119
+ | { kind: "unresponsive" }
111
120
  | { kind: "other-project"; serves?: string };
112
121
 
113
122
  /**
@@ -122,6 +131,14 @@ export type FleetDaemonProbe = {
122
131
  unit?: UnitOwnership;
123
132
  };
124
133
 
134
+ /**
135
+ * #685: the healthz timeout is a probe outcome, never a death verdict — the
136
+ * same discipline the indexer probe uses when it reports `probe timed out`
137
+ * instead of declaring the indexer missing.
138
+ */
139
+ const UNRESPONSIVE_HEALTHZ =
140
+ `unresponsive (healthz timed out after ${HEALTH_TIMEOUT_MS / 1000}s)`;
141
+
125
142
  function formatDaemonHealthz(probe: FleetDaemonProbe | undefined): string {
126
143
  if (probe === undefined) return "unprobed";
127
144
  switch (probe.project.kind) {
@@ -129,6 +146,8 @@ function formatDaemonHealthz(probe: FleetDaemonProbe | undefined): string {
129
146
  return "stopped";
130
147
  case "ok":
131
148
  return "ok";
149
+ case "unresponsive":
150
+ return UNRESPONSIVE_HEALTHZ;
132
151
  case "unreachable":
133
152
  return "unreachable — the process is up but not serving";
134
153
  case "other-project":
@@ -239,8 +258,13 @@ export function formatFleetStatus(
239
258
  // not this project's daemon facts (#379).
240
259
  const rss =
241
260
  daemon?.project.kind === "ok" ? rssBytesFromHealthz(daemon.body) : undefined;
261
+ // Three states, never two (#685): `not running` is reserved for a pid
262
+ // that is actually gone, and a timed-out `/healthz` is its own state —
263
+ // a healthy fleet that answers slowly must not read as stopped.
264
+ const state =
265
+ daemon?.project.kind === "unresponsive" ? UNRESPONSIVE_HEALTHZ : "running";
242
266
  daemonBlock = [
243
- "daemon",
267
+ `daemon ${state}`,
244
268
  ` pid ${layers.daemon.pid}`,
245
269
  ` port ${layers.daemon.port ?? "?"}`,
246
270
  ...(rss === undefined ? [] : [` rss ${formatRss(rss)}`]),
@@ -319,6 +343,31 @@ function formatDigestScheduleStatus(s: StatusSnapshot): string[] {
319
343
  ];
320
344
  }
321
345
 
346
+ /** The reporting row: which categories interrupt the operator's phone, and
347
+ * where everything else goes. It reads the snapshot's effective summary —
348
+ * the legacy preset name is a label, never the source of truth: an explicit
349
+ * policy without `scopePreset` renders truthfully from `interruptOn` and the
350
+ * digest cadence, so a routine outcome under `decisions` explains itself as
351
+ * digest-only instead of reading like a broken Telegram (#633). */
352
+ function formatReportingStatus(s: StatusSnapshot): string[] {
353
+ const r = s.reporting;
354
+ if (r === undefined) return [];
355
+ const interrupt =
356
+ r.interruptOn.length === 0 ? "nothing interrupts" : `${r.interruptOn.join(", ")} interrupt`;
357
+ const label = r.scopePreset === undefined ? "reporting —" : `reporting ${r.scopePreset} —`;
358
+ let consequence: string;
359
+ if (r.interruptOn.includes("material")) {
360
+ consequence = "no outcome waits for the digest";
361
+ } else if (r.digest.cadence === "daily") {
362
+ consequence = "material outcomes wait for the daily digest";
363
+ } else if (r.digest.cadence === "none") {
364
+ consequence = "material outcomes are held; no digest is configured";
365
+ } else {
366
+ consequence = "material outcomes wait for the per-tick digest";
367
+ }
368
+ return [`${label} ${interrupt}; ${consequence}`];
369
+ }
370
+
322
371
  /**
323
372
  * The shared-daemon visibility row (#545). When the daemon also serves sibling
324
373
  * projects, tell the reader how many runs each has live, so they can tell "my
@@ -342,6 +391,14 @@ function formatProjectBody(
342
391
  const lines = [
343
392
  `project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
344
393
  ...(s.pauseReason === undefined ? [] : [`paused ${s.pauseReason}`]),
394
+ // Structured-exposure half of the drain surface (#484): a plain row for
395
+ // the renderer, so the active drain is visible in `status` before the
396
+ // human-wording work of the later #484 child lands.
397
+ ...(s.drain === undefined
398
+ ? []
399
+ : [
400
+ `drain active — expires ${new Date(s.drain.expiresAt).toISOString()}, ${s.drain.remainingRuns} run(s) remaining`,
401
+ ]),
345
402
  `config ${s.configPath}`,
346
403
  `state ${s.stateDir}`,
347
404
  ...(siblingLine === undefined ? [] : [siblingLine]),
@@ -350,6 +407,7 @@ function formatProjectBody(
350
407
  ...(s.orchestratorDown === undefined
351
408
  ? []
352
409
  : formatOrchestratorDown(s.orchestratorDown, now)),
410
+ ...formatReportingStatus(s),
353
411
  ...formatAvailabilityStatus(s),
354
412
  ...formatDigestScheduleStatus(s),
355
413
  "",
@@ -433,20 +491,29 @@ function formatProjectBody(
433
491
  : round !== undefined
434
492
  ? `review-revision ${round}`
435
493
  : r.state;
436
- // Turn rate (#730): a stalled run and a fast one used to render
437
- // identically as a bare turn count. The rate is the lifetime average
438
- // over the elapsed shown — the snapshot carries no checkpoint from
439
- // which a recent-interval rate could be derived — so it is labelled
440
- // `avg`, and `elapsed` is elapsed since claim, never "remaining wall
441
- // clock": a paused worker banks its budget, which the snapshot cannot
442
- // know. A paused run gets neither number, because its elapsed includes
443
- // banked pause time and its state already says `paused`.
494
+ // Turn rate and cap projection (#730/#767): a stalled run and a fast one
495
+ // used to render identically as a bare turn count. The rate is the
496
+ // lifetime average over the elapsed shown — the snapshot carries no
497
+ // checkpoint from which a recent-interval rate could be derived — so it
498
+ // is labelled `avg`, and `elapsed` is elapsed since claim, never
499
+ // "remaining wall clock": a paused worker banks its budget, which the
500
+ // snapshot cannot know. #767 adds the projection #518 asked for: the
501
+ // same claim-elapsed rate held forward to the wall-clock budget
502
+ // (`caps.workerWallClockMs`, rendered in the caps block) as the turn
503
+ // count the run would reach at that cap — trajectory math over
504
+ // elapsed-so-far, so reading it against the run's `maxTurns` tells
505
+ // which cap fires first. A paused run gets neither number, because its
506
+ // elapsed includes banked pause time and its state already says
507
+ // `paused`.
444
508
  const elapsedMs = Math.max(0, now - r.startedAt);
445
509
  const progress = paused
446
510
  ? ""
447
511
  : ` ${formatDownDuration(elapsedMs)} elapsed ${(
448
512
  (r.turns * 60_000) / Math.max(elapsedMs, 1_000)
449
- ).toFixed(1)} turns/min avg`;
513
+ ).toFixed(1)} turns/min avg` +
514
+ ` projects ${Math.round(
515
+ (r.turns * s.caps.workerWallClockMs) / Math.max(elapsedMs, 1_000),
516
+ )} turns at cap`;
450
517
  lines.push(
451
518
  ` #${r.issue} ${r.repo} ${state} attempt ${r.attempt} ` +
452
519
  `${r.turns}/${r.maxTurns} turns${progress} ${r.spendUsd.toFixed(2)} ${r.branch}` +