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.
- package/README.md +34 -0
- package/REFERENCE.md +60 -10
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +29 -0
- package/src/admission.ts +204 -75
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +42 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +2 -0
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +22 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +50 -2
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +24 -0
- package/src/config.ts +42 -1
- package/src/daemon.ts +965 -36
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +235 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +146 -22
- 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 +567 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp.ts +227 -20
- package/src/orchestrator-tick.ts +1386 -15
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +99 -5
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +13 -2
- package/src/setup.ts +29 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +78 -11
- package/src/store.ts +443 -42
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +343 -13
- 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 +730 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +25 -2
- package/src/worktree.ts +29 -12
package/src/setup-install.ts
CHANGED
|
@@ -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."
|
package/src/setup-wizard.ts
CHANGED
|
@@ -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.
|
|
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: {
|
|
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,
|
|
582
|
-
*
|
|
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,
|
|
672
|
-
*
|
|
673
|
-
*
|
|
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
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/src/status-render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
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}` +
|