omp-conductor 0.18.0 → 0.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/REFERENCE.md +61 -11
- package/agents/to-spec.md +94 -0
- package/package.json +2 -1
- package/schema/config.schema.json +35 -1
- package/src/admission.ts +204 -75
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +62 -21
- package/src/briefs/to-spec.md +88 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +124 -1
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +38 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/intake.ts +4 -19
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +51 -16
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +43 -6
- package/src/config.ts +65 -9
- package/src/daemon.ts +879 -41
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +243 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +60 -82
- package/src/escalate.ts +31 -14
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +239 -240
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +242 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +183 -21
- package/src/orchestrator-tick.ts +1591 -32
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +65 -6
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1225 -9
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +154 -3
- package/src/setup.ts +83 -17
- package/src/shell.ts +15 -0
- package/src/status-render.ts +216 -12
- package/src/store.ts +443 -42
- package/src/to-spec.ts +408 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +405 -19
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +765 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +12 -2
- package/src/worktree.ts +29 -12
package/src/status-render.ts
CHANGED
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { formatZonedMinute } from "./availability.ts";
|
|
21
|
-
import type { DaemonStop } from "./types.ts";
|
|
21
|
+
import type { DaemonStop, GroomingRecord } from "./types.ts";
|
|
22
22
|
import { settlementFlagSummary } from "./diff-flags.ts";
|
|
23
23
|
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":
|
|
@@ -187,6 +206,141 @@ function formatLastStop(lastStop: DaemonStop | undefined): string[] {
|
|
|
187
206
|
}
|
|
188
207
|
|
|
189
208
|
|
|
209
|
+
// durable to-spec grooming lifecycle (#809)
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The durable mechanical-hold reasons admission's reconcile writes as blocked
|
|
214
|
+
* rows (#735) — the lane/dependency holds that clear by themselves. Mirrors
|
|
215
|
+
* `BLOCKED_GROOMING_HOLDS` in `store.ts`; the status reads the stored reason
|
|
216
|
+
* strings so both sides of the contract stay on the store vocabulary.
|
|
217
|
+
*/
|
|
218
|
+
const MECHANICAL_GROOMING_REASONS: Record<string, true> = {
|
|
219
|
+
"file-lane": true,
|
|
220
|
+
"depends-on": true,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The to-spec refusal classes persisted as blocked rows (#772) — a result
|
|
225
|
+
* that failed validation is a mechanical block, never a verdict. Mirrors the
|
|
226
|
+
* failure kinds of `ToSpecFailure` in `to-spec.ts`.
|
|
227
|
+
*/
|
|
228
|
+
const REFUSED_GROOMING_REASONS: Record<string, true> = {
|
|
229
|
+
malformed: true,
|
|
230
|
+
"missing-source": true,
|
|
231
|
+
"stale-source": true,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** The durable in-flight launch marker (#777) — a batch is running right now.
|
|
235
|
+
* Mirrors `TO_SPEC_IN_FLIGHT_REASON` in `orchestrator-tick.ts`. */
|
|
236
|
+
const GROOMING_IN_FLIGHT_REASON = "in-flight";
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* One project's durable grooming state as status lines, or nothing when there
|
|
240
|
+
* is nothing to report (#809).
|
|
241
|
+
*
|
|
242
|
+
* Renders the state already owned by the `grooming_verdicts` table plus the
|
|
243
|
+
* dispatch snapshot's parked count — each category below is derived from the
|
|
244
|
+
* project-scoped durable rows the caller passes, never from parsing evidence
|
|
245
|
+
* strings and never from a second cache:
|
|
246
|
+
*
|
|
247
|
+
* - `awaiting to-spec` — the routable queue candidates the last dispatch
|
|
248
|
+
* pass saw that no durable row covers: `routed − rows`, clamped at zero so
|
|
249
|
+
* stale rows (candidates since removed from the queue) cannot push it
|
|
250
|
+
* negative. `routed` is the same denominator the tick's grooming trigger
|
|
251
|
+
* reads (`summary.routed >= groomBelow`), and the fleet's queue-digest
|
|
252
|
+
* arithmetic already treats durable rows as current-queue facts
|
|
253
|
+
* (`claimable = routed − knownBlocked`), so this is the operator-facing
|
|
254
|
+
* half of the same subtraction. Parent/epic exclusions and backlog
|
|
255
|
+
* candidates that never entered the queue are tracker-side and not
|
|
256
|
+
* store-knowable; the row lines below carry the durable results either
|
|
257
|
+
* way.
|
|
258
|
+
* - `promotable` / `considered` / `blocked` — completed to-spec results
|
|
259
|
+
* (`blocked` rows whose reason is a groomer verdict or a product-judgement
|
|
260
|
+
* label, e.g. `needs-product-decision`).
|
|
261
|
+
* - `mechanically blocked` — admission's lane/dependency holds.
|
|
262
|
+
* - `refused` — to-spec results that failed validation (`malformed`,
|
|
263
|
+
* `missing-source`, `stale-source`), told apart from the holds so an
|
|
264
|
+
* operator sees whether the runway cannot move or a result cannot be
|
|
265
|
+
* trusted.
|
|
266
|
+
* - `in-flight` — a launched batch is running (#777).
|
|
267
|
+
* - `operator-parked` — the dispatch snapshot's parked count (#507).
|
|
268
|
+
*
|
|
269
|
+
* Rendering is read-only: nothing here reconciles, promotes, retries, or
|
|
270
|
+
* mutates grooming records. Empty categories are omitted — zero-value rows
|
|
271
|
+
* would be noise, and an absent block means a fleet with no grooming state.
|
|
272
|
+
*/
|
|
273
|
+
export interface GroomingStatusInput {
|
|
274
|
+
/** Project-scoped durable rows (`Store.groomingVerdicts`), issue-ascending. */
|
|
275
|
+
records: readonly GroomingRecord[];
|
|
276
|
+
/** Routable queue candidates from the last dispatch pass
|
|
277
|
+
* (`DispatchSummary.routed`). */
|
|
278
|
+
routed: number;
|
|
279
|
+
/** Operator-parked candidates from the last dispatch pass
|
|
280
|
+
* (`DispatchSummary.parked`). */
|
|
281
|
+
parked: number;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** How many issue identifiers one category line may carry — enough to act on,
|
|
285
|
+
* never a queue dump. Mirrors the dispatch hold sampling. */
|
|
286
|
+
const GROOMING_STATUS_SAMPLE = 5;
|
|
287
|
+
|
|
288
|
+
/** One category line: label, count, and a bounded sample of issue identifiers
|
|
289
|
+
* with their durable reasons where the reason differs from the line label
|
|
290
|
+
* (e.g. `#13 already-done` under `considered`, `#19 malformed` under
|
|
291
|
+
* `refused`). Absent at zero. */
|
|
292
|
+
function groomingStatusLine(
|
|
293
|
+
label: string,
|
|
294
|
+
rows: readonly GroomingRecord[],
|
|
295
|
+
): string | undefined {
|
|
296
|
+
if (rows.length === 0) return undefined;
|
|
297
|
+
const sample = rows
|
|
298
|
+
.slice(0, GROOMING_STATUS_SAMPLE)
|
|
299
|
+
.map((row) => (row.reason === label ? `#${row.issue}` : `#${row.issue} ${row.reason}`));
|
|
300
|
+
const more = rows.length > GROOMING_STATUS_SAMPLE ? ", …" : "";
|
|
301
|
+
return ` ${label.padEnd(21)}${rows.length} ${sample.join(", ")}${more}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function formatGroomingStatus(input: GroomingStatusInput): string | undefined {
|
|
305
|
+
const { records, routed, parked } = input;
|
|
306
|
+
const promotable = records.filter((row) => row.verdict === "promotable");
|
|
307
|
+
const considered = records.filter((row) => row.verdict === "considered");
|
|
308
|
+
const blockedResults = records.filter(
|
|
309
|
+
(row) =>
|
|
310
|
+
row.verdict === "blocked" &&
|
|
311
|
+
MECHANICAL_GROOMING_REASONS[row.reason] !== true &&
|
|
312
|
+
REFUSED_GROOMING_REASONS[row.reason] !== true &&
|
|
313
|
+
row.reason !== GROOMING_IN_FLIGHT_REASON,
|
|
314
|
+
);
|
|
315
|
+
const mechanical = records.filter(
|
|
316
|
+
(row) => row.verdict === "blocked" && MECHANICAL_GROOMING_REASONS[row.reason] === true,
|
|
317
|
+
);
|
|
318
|
+
const refused = records.filter(
|
|
319
|
+
(row) => row.verdict === "blocked" && REFUSED_GROOMING_REASONS[row.reason] === true,
|
|
320
|
+
);
|
|
321
|
+
const inFlight = records.filter(
|
|
322
|
+
(row) => row.verdict === "blocked" && row.reason === GROOMING_IN_FLIGHT_REASON,
|
|
323
|
+
);
|
|
324
|
+
const awaiting = Math.max(0, routed - records.length);
|
|
325
|
+
const lines: string[] = [];
|
|
326
|
+
if (awaiting > 0) {
|
|
327
|
+
lines.push(` ${"awaiting to-spec".padEnd(21)}${awaiting} (of ${routed} routable)`);
|
|
328
|
+
}
|
|
329
|
+
for (const line of [
|
|
330
|
+
groomingStatusLine("promotable", promotable),
|
|
331
|
+
groomingStatusLine("considered", considered),
|
|
332
|
+
groomingStatusLine("blocked", blockedResults),
|
|
333
|
+
groomingStatusLine("mechanically blocked", mechanical),
|
|
334
|
+
groomingStatusLine("refused", refused),
|
|
335
|
+
groomingStatusLine("in-flight", inFlight),
|
|
336
|
+
]) {
|
|
337
|
+
if (line !== undefined) lines.push(line);
|
|
338
|
+
}
|
|
339
|
+
if (parked > 0) lines.push(` ${"operator-parked".padEnd(21)}${parked}`);
|
|
340
|
+
if (lines.length === 0) return undefined;
|
|
341
|
+
return ["grooming (to-spec)", ...lines].join("\n");
|
|
342
|
+
}
|
|
343
|
+
|
|
190
344
|
export function formatFleetStatus(
|
|
191
345
|
s: StatusSnapshot,
|
|
192
346
|
layers: FleetLayers,
|
|
@@ -201,6 +355,7 @@ export function formatFleetStatus(
|
|
|
201
355
|
intake: string | undefined = undefined,
|
|
202
356
|
lastStop: DaemonStop | undefined = undefined,
|
|
203
357
|
siblings: { project: string; live: number }[] = [],
|
|
358
|
+
grooming: string | undefined = undefined,
|
|
204
359
|
): string {
|
|
205
360
|
const tickLine =
|
|
206
361
|
layers.ticksDetail === undefined
|
|
@@ -239,8 +394,13 @@ export function formatFleetStatus(
|
|
|
239
394
|
// not this project's daemon facts (#379).
|
|
240
395
|
const rss =
|
|
241
396
|
daemon?.project.kind === "ok" ? rssBytesFromHealthz(daemon.body) : undefined;
|
|
397
|
+
// Three states, never two (#685): `not running` is reserved for a pid
|
|
398
|
+
// that is actually gone, and a timed-out `/healthz` is its own state —
|
|
399
|
+
// a healthy fleet that answers slowly must not read as stopped.
|
|
400
|
+
const state =
|
|
401
|
+
daemon?.project.kind === "unresponsive" ? UNRESPONSIVE_HEALTHZ : "running";
|
|
242
402
|
daemonBlock = [
|
|
243
|
-
|
|
403
|
+
`daemon ${state}`,
|
|
244
404
|
` pid ${layers.daemon.pid}`,
|
|
245
405
|
` port ${layers.daemon.port ?? "?"}`,
|
|
246
406
|
...(rss === undefined ? [] : [` rss ${formatRss(rss)}`]),
|
|
@@ -282,6 +442,7 @@ export function formatFleetStatus(
|
|
|
282
442
|
...(decisions === undefined ? [] : [decisions]),
|
|
283
443
|
...(failureClasses === undefined ? [] : [failureClasses]),
|
|
284
444
|
...(intake === undefined ? [] : [intake]),
|
|
445
|
+
...(grooming === undefined ? [] : [grooming]),
|
|
285
446
|
...(graphBlock === undefined ? [] : [graphBlock]),
|
|
286
447
|
daemonBlock,
|
|
287
448
|
...formatLastStop(lastStop),
|
|
@@ -319,6 +480,31 @@ function formatDigestScheduleStatus(s: StatusSnapshot): string[] {
|
|
|
319
480
|
];
|
|
320
481
|
}
|
|
321
482
|
|
|
483
|
+
/** The reporting row: which categories interrupt the operator's phone, and
|
|
484
|
+
* where everything else goes. It reads the snapshot's effective summary —
|
|
485
|
+
* the legacy preset name is a label, never the source of truth: an explicit
|
|
486
|
+
* policy without `scopePreset` renders truthfully from `interruptOn` and the
|
|
487
|
+
* digest cadence, so a routine outcome under `decisions` explains itself as
|
|
488
|
+
* digest-only instead of reading like a broken Telegram (#633). */
|
|
489
|
+
function formatReportingStatus(s: StatusSnapshot): string[] {
|
|
490
|
+
const r = s.reporting;
|
|
491
|
+
if (r === undefined) return [];
|
|
492
|
+
const interrupt =
|
|
493
|
+
r.interruptOn.length === 0 ? "nothing interrupts" : `${r.interruptOn.join(", ")} interrupt`;
|
|
494
|
+
const label = r.scopePreset === undefined ? "reporting —" : `reporting ${r.scopePreset} —`;
|
|
495
|
+
let consequence: string;
|
|
496
|
+
if (r.interruptOn.includes("material")) {
|
|
497
|
+
consequence = "no outcome waits for the digest";
|
|
498
|
+
} else if (r.digest.cadence === "daily") {
|
|
499
|
+
consequence = "material outcomes wait for the daily digest";
|
|
500
|
+
} else if (r.digest.cadence === "none") {
|
|
501
|
+
consequence = "material outcomes are held; no digest is configured";
|
|
502
|
+
} else {
|
|
503
|
+
consequence = "material outcomes wait for the per-tick digest";
|
|
504
|
+
}
|
|
505
|
+
return [`${label} ${interrupt}; ${consequence}`];
|
|
506
|
+
}
|
|
507
|
+
|
|
322
508
|
/**
|
|
323
509
|
* The shared-daemon visibility row (#545). When the daemon also serves sibling
|
|
324
510
|
* projects, tell the reader how many runs each has live, so they can tell "my
|
|
@@ -342,6 +528,14 @@ function formatProjectBody(
|
|
|
342
528
|
const lines = [
|
|
343
529
|
`project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
|
|
344
530
|
...(s.pauseReason === undefined ? [] : [`paused ${s.pauseReason}`]),
|
|
531
|
+
// Structured-exposure half of the drain surface (#484): a plain row for
|
|
532
|
+
// the renderer, so the active drain is visible in `status` before the
|
|
533
|
+
// human-wording work of the later #484 child lands.
|
|
534
|
+
...(s.drain === undefined
|
|
535
|
+
? []
|
|
536
|
+
: [
|
|
537
|
+
`drain active — expires ${new Date(s.drain.expiresAt).toISOString()}, ${s.drain.remainingRuns} run(s) remaining`,
|
|
538
|
+
]),
|
|
345
539
|
`config ${s.configPath}`,
|
|
346
540
|
`state ${s.stateDir}`,
|
|
347
541
|
...(siblingLine === undefined ? [] : [siblingLine]),
|
|
@@ -350,6 +544,7 @@ function formatProjectBody(
|
|
|
350
544
|
...(s.orchestratorDown === undefined
|
|
351
545
|
? []
|
|
352
546
|
: formatOrchestratorDown(s.orchestratorDown, now)),
|
|
547
|
+
...formatReportingStatus(s),
|
|
353
548
|
...formatAvailabilityStatus(s),
|
|
354
549
|
...formatDigestScheduleStatus(s),
|
|
355
550
|
"",
|
|
@@ -433,20 +628,29 @@ function formatProjectBody(
|
|
|
433
628
|
: round !== undefined
|
|
434
629
|
? `review-revision ${round}`
|
|
435
630
|
: r.state;
|
|
436
|
-
// Turn rate (#730): a stalled run and a fast one
|
|
437
|
-
// identically as a bare turn count. The rate is the
|
|
438
|
-
// over the elapsed shown — the snapshot carries no
|
|
439
|
-
// which a recent-interval rate could be derived — so it
|
|
440
|
-
// `avg`, and `elapsed` is elapsed since claim, never
|
|
441
|
-
// clock": a paused worker banks its budget, which the
|
|
442
|
-
// know.
|
|
443
|
-
//
|
|
631
|
+
// Turn rate and cap projection (#730/#767): a stalled run and a fast one
|
|
632
|
+
// used to render identically as a bare turn count. The rate is the
|
|
633
|
+
// lifetime average over the elapsed shown — the snapshot carries no
|
|
634
|
+
// checkpoint from which a recent-interval rate could be derived — so it
|
|
635
|
+
// is labelled `avg`, and `elapsed` is elapsed since claim, never
|
|
636
|
+
// "remaining wall clock": a paused worker banks its budget, which the
|
|
637
|
+
// snapshot cannot know. #767 adds the projection #518 asked for: the
|
|
638
|
+
// same claim-elapsed rate held forward to the wall-clock budget
|
|
639
|
+
// (`caps.workerWallClockMs`, rendered in the caps block) as the turn
|
|
640
|
+
// count the run would reach at that cap — trajectory math over
|
|
641
|
+
// elapsed-so-far, so reading it against the run's `maxTurns` tells
|
|
642
|
+
// which cap fires first. A paused run gets neither number, because its
|
|
643
|
+
// elapsed includes banked pause time and its state already says
|
|
644
|
+
// `paused`.
|
|
444
645
|
const elapsedMs = Math.max(0, now - r.startedAt);
|
|
445
646
|
const progress = paused
|
|
446
647
|
? ""
|
|
447
648
|
: ` ${formatDownDuration(elapsedMs)} elapsed ${(
|
|
448
649
|
(r.turns * 60_000) / Math.max(elapsedMs, 1_000)
|
|
449
|
-
).toFixed(1)} turns/min avg
|
|
650
|
+
).toFixed(1)} turns/min avg` +
|
|
651
|
+
` projects ${Math.round(
|
|
652
|
+
(r.turns * s.caps.workerWallClockMs) / Math.max(elapsedMs, 1_000),
|
|
653
|
+
)} turns at cap`;
|
|
450
654
|
lines.push(
|
|
451
655
|
` #${r.issue} ${r.repo} ${state} attempt ${r.attempt} ` +
|
|
452
656
|
`${r.turns}/${r.maxTurns} turns${progress} ${r.spendUsd.toFixed(2)} ${r.branch}` +
|