omp-conductor 0.19.7 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/arm-challenge.ts +204 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +253 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +113 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +412 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +736 -0
- package/src/daemon/settle-pass.ts +589 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +24 -3
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +290 -164
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +158 -7
- package/src/store.ts +604 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +416 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam every dispatch pass is written against.
|
|
3
|
+
*
|
|
4
|
+
* `Deps` is the reason this directory can exist at all: a pass takes the one
|
|
5
|
+
* struct and touches nothing else, so it can move to its own module without
|
|
6
|
+
* learning about its neighbours. That also makes this module the test of
|
|
7
|
+
* whether a cut is in the right place — a pass that needs something no `Deps`
|
|
8
|
+
* field can reach was split along the wrong line.
|
|
9
|
+
*
|
|
10
|
+
* Three kinds of thing live here and nothing else:
|
|
11
|
+
*
|
|
12
|
+
* - `Deps` itself, the gate and registry interfaces it is made of, and the
|
|
13
|
+
* intervals `runDaemon` schedules against. The gate types sit here rather
|
|
14
|
+
* than beside their implementations so `Deps` never imports a pass, and no
|
|
15
|
+
* pass imports another merely to name a field it already holds.
|
|
16
|
+
* - `verbDeps` and `recordDaemonSessionSpend`: the two projections of `Deps`
|
|
17
|
+
* that more than one pass performs. Duplicated they would be two answers to
|
|
18
|
+
* one question; parked inside one pass they would make the others import it
|
|
19
|
+
* sideways.
|
|
20
|
+
* - the pure identity and classification leaves the passes share — `repoSlug`,
|
|
21
|
+
* `knowledgeRepoKey`, `githubRepo`, `completionLastError`,
|
|
22
|
+
* `exhaustedSessionReason`. A shared floor with no upstream of its own is
|
|
23
|
+
* what keeps the rest of this directory acyclic.
|
|
24
|
+
*
|
|
25
|
+
* `PACKAGE_SRC_DIR` is the one declaration here that is not a move. Modules
|
|
26
|
+
* under `daemon/` sit one directory deeper than the code they came from, so
|
|
27
|
+
* every `import.meta.dir` read that meant "the installed `src/`" — the brief
|
|
28
|
+
* templates, the integrity manifest root, the two lines that print the package
|
|
29
|
+
* root — resolves through this constant rather than silently re-deriving a
|
|
30
|
+
* path one level too deep.
|
|
31
|
+
*/
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
34
|
+
import type { CredentialClassProbeResult } from "../credential-class.ts";
|
|
35
|
+
import type { HerdrRun } from "../fleet.ts";
|
|
36
|
+
import { probeRunLane, readBaseChain, type CriticalBaseProbe, type RunLaneProbe } from "../gitops.ts";
|
|
37
|
+
import { errText, log } from "../log.ts";
|
|
38
|
+
import type { OrchestratorHandle } from "../orchestrator.ts";
|
|
39
|
+
import { isPaused, pausedAt } from "../pause.ts";
|
|
40
|
+
import type { UnroutableReason } from "../routing.ts";
|
|
41
|
+
import type { TelegramFreshness } from "../telegram-freshness.ts";
|
|
42
|
+
import type { Caps, Escalation, FailureClass, HostConstraints, IssueSnapshot, ProjectConfig, RepoTarget, RunRecord, Store, Tracker } from "../types.ts";
|
|
43
|
+
import type { InstalledSurfaces } from "../upgrade.ts";
|
|
44
|
+
import type { UsageSource } from "../usage.ts";
|
|
45
|
+
import { githubVerbActions } from "../verbs/actions.ts";
|
|
46
|
+
import type { VerbActions, VerbDeps } from "../verbs/server.ts";
|
|
47
|
+
import type { PeerReader } from "../verbs/socket.ts";
|
|
48
|
+
import type { AdjudicationResult, AdjudicatorOpts, RunWorkerDeps, ToSpecScoutOpts, ToSpecScoutResult, WorkerPauseControl, WorkerPausePhase } from "../worker.ts";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The installed `src/` — the directory `daemon.ts` used to be `import.meta.dir`
|
|
52
|
+
* of, before its passes moved one level down into `daemon/`.
|
|
53
|
+
*
|
|
54
|
+
* Three reads depend on that exact path and would break silently one directory
|
|
55
|
+
* deeper: the brief templates under `src/briefs/`, the integrity manifest root
|
|
56
|
+
* (which is the package's self-portrait, so a wrong root would hash a tenth of
|
|
57
|
+
* it and call the rest unchanged), and the two operator-facing lines that print
|
|
58
|
+
* the package root. One constant so all five sites keep the same answer.
|
|
59
|
+
*/
|
|
60
|
+
export const PACKAGE_SRC_DIR = join(import.meta.dir, "..");
|
|
61
|
+
|
|
62
|
+
/** Long enough that the tracker is not polled raw, short enough that a human
|
|
63
|
+
* who labels an issue sees it picked up within a coffee break. */
|
|
64
|
+
export const TICK_INTERVAL_MS = 5 * 60_000;
|
|
65
|
+
export const GRAPH_HEALTH_INTERVAL_MS = 60_000;
|
|
66
|
+
/** Report delivery runs on its own timer, not the five-minute dispatch tick: a
|
|
67
|
+
* report an operator is waiting on must not sit in the outbox for the length of
|
|
68
|
+
* a poll interval, and delivery is owed even while claiming is paused (#123). */
|
|
69
|
+
export const REPORT_DELIVERY_INTERVAL_MS = 30_000;
|
|
70
|
+
|
|
71
|
+
export const DEFAULT_PORT = 8787;
|
|
72
|
+
|
|
73
|
+
/** Fleet-wide escalations still need an issue number in the payload; 0 is the
|
|
74
|
+
* sentinel that reads as "no issue" in every renderer. */
|
|
75
|
+
export const NO_ISSUE = 0;
|
|
76
|
+
|
|
77
|
+
export const UNROUTABLE_TEXT: Record<UnroutableReason, string> = {
|
|
78
|
+
"no-repo-label": "it carries no repo label",
|
|
79
|
+
"multiple-repo-labels": "it carries more than one repo label",
|
|
80
|
+
"unknown-repo": "its repo label maps to no configured repo",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export interface DaemonOpts {
|
|
84
|
+
once?: boolean;
|
|
85
|
+
port?: number;
|
|
86
|
+
project?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Everything one tick touches. `project` and `caps` are re-resolved at each
|
|
90
|
+
* tick boundary so an operator's config edit applies on the next tick rather
|
|
91
|
+
* than the next daemon restart (#170); a tick and the runs it admits see one
|
|
92
|
+
* consistent snapshot, and a mid-run edit never changes a live run's labels,
|
|
93
|
+
* model or caps — `handleIssue` destructures them at dispatch time. The rest
|
|
94
|
+
* are resolved once at startup so a tick never re-reads config mid-flight and
|
|
95
|
+
* changes its own limits underneath itself. */
|
|
96
|
+
export interface DrainSignal {
|
|
97
|
+
draining: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface Deps {
|
|
101
|
+
project: ProjectConfig;
|
|
102
|
+
caps: Caps;
|
|
103
|
+
/** The typed host-constraints block (#721), re-resolved at the tick
|
|
104
|
+
* boundary like `project`/`caps`, and rendered into every brief the tick
|
|
105
|
+
* dispatches. Optional so tests that never exercise it can omit it. */
|
|
106
|
+
host?: HostConstraints;
|
|
107
|
+
tracker: Tracker;
|
|
108
|
+
store: Store;
|
|
109
|
+
/**
|
|
110
|
+
* Set true the moment a daemon stop (SIGTERM/SIGINT) begins. The run loop
|
|
111
|
+
* only sees `stopping` between whole ticks, so a pass that was already in
|
|
112
|
+
* flight when the stop landed must re-check this flag itself — before it
|
|
113
|
+
* claims and before it launches — or it creates exactly the work the
|
|
114
|
+
* shutdown is about to wait for and then lose to the stop timeout (#374).
|
|
115
|
+
* Optional only so tests that never exercise shutdown can omit it;
|
|
116
|
+
* `runDaemon` always wires the real one.
|
|
117
|
+
*/
|
|
118
|
+
drain?: DrainSignal;
|
|
119
|
+
/** False after a live config reload fails; autonomous delivery then holds
|
|
120
|
+
* fail-closed until a later tick validates the config again. */
|
|
121
|
+
deliveryPolicyValid?: boolean;
|
|
122
|
+
/** Provider-reported plan allowance, cached with a TTL. Resolved once at
|
|
123
|
+
* startup like every other dep so a tick cannot swap its own meter. */
|
|
124
|
+
usage: UsageSource;
|
|
125
|
+
escalate(e: Escalation): Promise<void>;
|
|
126
|
+
turnLimits: TurnLimitRegistry;
|
|
127
|
+
workerControls: WorkerControlRegistry;
|
|
128
|
+
/** Session seam for lifecycle integration tests; production uses the real harness. */
|
|
129
|
+
workerDeps?: RunWorkerDeps;
|
|
130
|
+
/** Harness per-process logs used to classify terminal worker routes. */
|
|
131
|
+
harnessLogDir?: string;
|
|
132
|
+
/**
|
|
133
|
+
* The Herdr runner for the worker-representation surface (#1035). Production
|
|
134
|
+
* leaves it absent — the real `herdr` CLI answers; lifecycle tests inject a
|
|
135
|
+
* fake so convergence is proven without a terminal.
|
|
136
|
+
*/
|
|
137
|
+
herdrRun?: HerdrRun;
|
|
138
|
+
integrity: IntegrityGate;
|
|
139
|
+
stall: StallGate;
|
|
140
|
+
/**
|
|
141
|
+
* Stops the wedged orchestrator by exact identity, for the bounded
|
|
142
|
+
* auto-restart ({@link restartWedgedOrchestrator}). Production leaves it
|
|
143
|
+
* absent and `stopConductorPane` answers — the same resolver `halt --pane`
|
|
144
|
+
* uses, so there is exactly one implementation of "which pane is the
|
|
145
|
+
* orchestrator". A test injects a fake, which is what makes the counter, the
|
|
146
|
+
* bound and the record-before-attempt ordering assertable without a terminal.
|
|
147
|
+
*/
|
|
148
|
+
restartOrchestrator?: (
|
|
149
|
+
projectName: string,
|
|
150
|
+
) => Promise<{ stopped: "herdr-agent" | "already-gone"; detail: string; agentName: string }>;
|
|
151
|
+
/**
|
|
152
|
+
* The embedded orchestrator session handle when one started; absent when it
|
|
153
|
+
* failed to start or the project uses an external orchestrator. Feeds the
|
|
154
|
+
* orchestrator-down reconcile ({@link reconcileOrchestratorDown}) so a
|
|
155
|
+
* crashed session pages once per incident instead of degrading quietly.
|
|
156
|
+
*/
|
|
157
|
+
orchestrator?: OrchestratorHandle;
|
|
158
|
+
cleanup?: RetainedCleanupCursor;
|
|
159
|
+
/**
|
|
160
|
+
* Reads the connecting uid off a verb socket (#126). Resolved once at startup
|
|
161
|
+
* so the mechanism is logged before the first socket exists; absent on a host
|
|
162
|
+
* exposing no peer-credential call, where the `0600` socket under the
|
|
163
|
+
* daemon-owned `0711` parent is all that keeps other local accounts out, and
|
|
164
|
+
* `status` says so. It never told two sessions apart — they share the daemon's
|
|
165
|
+
* uid.
|
|
166
|
+
*/
|
|
167
|
+
verbPeerReader?: PeerReader;
|
|
168
|
+
/**
|
|
169
|
+
* The privileged half the verbs call once their checks pass. Optional only so
|
|
170
|
+
* a test can build a `Deps` without a repository; `runDaemon` always wires
|
|
171
|
+
* the real one.
|
|
172
|
+
*/
|
|
173
|
+
verbActions?: VerbActions;
|
|
174
|
+
/**
|
|
175
|
+
* The fleet-installs-itself verification pass (#486), run on the first tick
|
|
176
|
+
* after a detached upgrade. The default is the journal-gated
|
|
177
|
+
* {@link verifyPendingUpgradeTick}; a test injects its own to drive the tick
|
|
178
|
+
* without touching the host's packages, journal or outbox.
|
|
179
|
+
*/
|
|
180
|
+
upgradeVerifier?: (d: Deps) => Promise<void>;
|
|
181
|
+
/**
|
|
182
|
+
* Answers whether a preserved continuation branch contains every configured
|
|
183
|
+
* critical-base marker, for the stale-base admission hold (#428). Wired by
|
|
184
|
+
* `runDaemon` to the mirror-backed {@link probeCriticalBase}; a test injects
|
|
185
|
+
* a fake. Absent, the stale-base gate fails closed whenever a project names
|
|
186
|
+
* a marker (a safety interlock must not silently weaken).
|
|
187
|
+
*/
|
|
188
|
+
probeCriticalBase?: CriticalBaseProbe;
|
|
189
|
+
/**
|
|
190
|
+
* Reads one active run's file lane for the admission file-lane interlock
|
|
191
|
+
* (#555): the union of its uncommitted worktree changes and its branch-vs-base
|
|
192
|
+
* diff. Reconciliation is not occupancy: while the run merges its base, the
|
|
193
|
+
* probe reports only files it has actually diverged on, never the files the
|
|
194
|
+
* merge merely staged (#684). Wired by `runDaemon` to the mirror/worktree-backed
|
|
195
|
+
* {@link probeRunLane}; a test injects a fake. Absent, the interlock is inert
|
|
196
|
+
* (no lane is ever known occupied), which is the issue's "fail open": the
|
|
197
|
+
* gate adds holds, it never refuses a well-formed issue for lack of this
|
|
198
|
+
* probe the way `criticalBase` does.
|
|
199
|
+
*/
|
|
200
|
+
probeWorktreeLane?: RunLaneProbe;
|
|
201
|
+
/**
|
|
202
|
+
* Reads one provider's current credential class from the harness, out of
|
|
203
|
+
* process (#852). Wired by `runDaemon` to {@link probeCredentialClass}; a test
|
|
204
|
+
* injects a fake.
|
|
205
|
+
*
|
|
206
|
+
* Absent, both fences refuse a project that declares `requireOauthProviders` —
|
|
207
|
+
* this one fails CLOSED, unlike `probeWorktreeLane`, because an unverified
|
|
208
|
+
* credential class costs exactly what a wrong one costs. A project declaring
|
|
209
|
+
* nothing never calls it, so an unwired test dispatches as it always has.
|
|
210
|
+
*/
|
|
211
|
+
probeCredentialClass?: (provider: string) => Promise<CredentialClassProbeResult>;
|
|
212
|
+
/**
|
|
213
|
+
* Reads one issue's tracker state in a repository the admission tracker is
|
|
214
|
+
* not bound to — the cross-repo Depends-on interlock (#420). Wired by
|
|
215
|
+
* `runDaemon` to a repo-scoped tracker; a test injects a fake. Absent,
|
|
216
|
+
* admission fails a routed cross-repo prerequisite closed.
|
|
217
|
+
*/
|
|
218
|
+
probeIssueIn?: (repo: string, issue: number) => Promise<IssueSnapshot | undefined>;
|
|
219
|
+
/**
|
|
220
|
+
* Reads one issue's BODY in a repository the admission tracker is not bound
|
|
221
|
+
* to — the dependency-graph cycle pass (#421). Wired by `runDaemon` to a
|
|
222
|
+
* repo-scoped tracker; a test injects a fake. Absent, a routed reachable
|
|
223
|
+
* body fails that branch closed rather than synthesising a cycle.
|
|
224
|
+
*/
|
|
225
|
+
probeBodyIn?: (repo: string, issue: number) => Promise<string | undefined>;
|
|
226
|
+
/**
|
|
227
|
+
* Reads the three install identities this host carries (#919). Wired by
|
|
228
|
+
* `runDaemon` to `inspectSurfaces`, the same seam `doctor` and `upgrade`
|
|
229
|
+
* read, so nothing re-implements the probe. A test injects its own; absent,
|
|
230
|
+
* the pass records nothing and every cheap surface honestly says "not
|
|
231
|
+
* observed yet" rather than claiming agreement.
|
|
232
|
+
*/
|
|
233
|
+
probeInstallSurfaces?: () => Promise<InstalledSurfaces>;
|
|
234
|
+
/** The `omp-telegram` install/daemon/published triple, read on the same
|
|
235
|
+
* periodic pass as the surfaces above (#961). */
|
|
236
|
+
probeTelegramFreshness?: () => Promise<TelegramFreshness>;
|
|
237
|
+
/**
|
|
238
|
+
* Runs one review-ceiling adjudication (#932). Production is
|
|
239
|
+
* {@link runAdjudicator}; a test injects a fake so the assertion can be the
|
|
240
|
+
* launch arguments and the assembled brief — which is what this pass actually
|
|
241
|
+
* produces — rather than a stored flag.
|
|
242
|
+
*/
|
|
243
|
+
runAdjudicatorImpl?: (opts: AdjudicatorOpts) => Promise<AdjudicationResult>;
|
|
244
|
+
/**
|
|
245
|
+
* Runs one to-spec grooming pass (#1041). Production is
|
|
246
|
+
* {@link runToSpecScout}; a test injects a fake, which is what makes the
|
|
247
|
+
* whole mechanical pipeline — select, claim, persist, gate, promote, wake —
|
|
248
|
+
* assertable end to end without a session or a network.
|
|
249
|
+
*/
|
|
250
|
+
runToSpecScoutImpl?: (opts: ToSpecScoutOpts) => Promise<ToSpecScoutResult>;
|
|
251
|
+
/**
|
|
252
|
+
* The dispatch wake fired after a queue-label hand-back or a mechanical
|
|
253
|
+
* promotion (#1041) — the same seam `settlement.ts` reads, so one injected
|
|
254
|
+
* spy covers every requeue path a tick runs. Production leaves it absent and
|
|
255
|
+
* the real loopback client answers: from inside the daemon that is a POST to
|
|
256
|
+
* its own `/wake`, which is deliberate rather than a shortcut — the wake is
|
|
257
|
+
* one coalescing flag with one owner, and a second in-process path to it
|
|
258
|
+
* would be a second answer to "is a pass pending".
|
|
259
|
+
*/
|
|
260
|
+
wake?: (projectName: string) => Promise<string>;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The daemon-side view a verb call decides against (#126).
|
|
265
|
+
*
|
|
266
|
+
* `project` is a thunk, not the resolved value the rest of a tick uses. That is
|
|
267
|
+
* the whole "fail closed on an unreadable config" rule: a config read once at
|
|
268
|
+
* boot can never become unreadable, and it cannot pick up an operator who has
|
|
269
|
+
* just taken merge authority back either. Every verb pays one file read for
|
|
270
|
+
* the property that its answer reflects the config as it is *now*.
|
|
271
|
+
*
|
|
272
|
+
* `fleetStop` is read the same way and for the same reason — inside the verb,
|
|
273
|
+
* after the model decided to call it. `hold` and `halt` both set the pause
|
|
274
|
+
* sentinel alongside disarming ticks, so this one read covers both.
|
|
275
|
+
*/
|
|
276
|
+
export function verbDeps(d: Pick<Deps, "project" | "store" | "tracker" | "verbActions">): VerbDeps {
|
|
277
|
+
return {
|
|
278
|
+
project: () => findProject(loadConfig(), d.project.name),
|
|
279
|
+
projects: () => loadConfig().projects,
|
|
280
|
+
store: d.store,
|
|
281
|
+
tracker: d.tracker,
|
|
282
|
+
actions: d.verbActions ?? githubVerbActions(d.project),
|
|
283
|
+
fleetStop: () =>
|
|
284
|
+
isPaused(d.project.name)
|
|
285
|
+
? "claiming is paused for this fleet (omp-conductor hold or stop)"
|
|
286
|
+
: undefined,
|
|
287
|
+
pausedAt: () => pausedAt(d.project.name),
|
|
288
|
+
log,
|
|
289
|
+
now: () => Date.now(),
|
|
290
|
+
chain: { readBaseChain },
|
|
291
|
+
lane: { probeRunLane },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Whether the wedged-orchestrator page has gone out, and when it last went out,
|
|
297
|
+
* for the stall currently on disk.
|
|
298
|
+
*/
|
|
299
|
+
export interface StallGate {
|
|
300
|
+
paged: boolean;
|
|
301
|
+
lastPagedAt?: number;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* What the daemon booted with, and whether it has already paged about losing
|
|
306
|
+
* it. Lives exactly as long as one `runDaemon()` call — which is the whole
|
|
307
|
+
* trick: a restart re-records both.
|
|
308
|
+
*/
|
|
309
|
+
export interface IntegrityGate {
|
|
310
|
+
baseline: Map<string, string>;
|
|
311
|
+
paged: boolean;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* `owner/repo` for `gh`, derived from the clone URL.
|
|
316
|
+
*
|
|
317
|
+
* ponytail: RepoTarget has no explicit slug, so it is parsed off the URL and
|
|
318
|
+
* falls back to the routing name. Upgrade path is an optional `slug` field once
|
|
319
|
+
* a non-GitHub remote actually shows up.
|
|
320
|
+
*/
|
|
321
|
+
export function repoSlug(repo: RepoTarget): string {
|
|
322
|
+
const m = /(?:[:/])([^/:]+\/[^/]+?)(?:\.git)?$/.exec(repo.cloneUrl);
|
|
323
|
+
return m?.[1] ?? repo.name;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The identity the per-repo knowledge overlay is filed under: the canonical
|
|
328
|
+
* `owner/repo` when the clone URL names one, else the routed name.
|
|
329
|
+
*
|
|
330
|
+
* Deliberately NOT {@link repoSlug}: that one is a loose parse whose job is to
|
|
331
|
+
* *say something* in a brief for any URL shape, so a filesystem path yields a
|
|
332
|
+
* plausible-looking pair of directory names. A knowledge file keyed off that
|
|
333
|
+
* would be keyed off where a mirror happens to live. One function, used by both
|
|
334
|
+
* the writer at settlement and the reader in the brief, so the two can never
|
|
335
|
+
* file and read under different names.
|
|
336
|
+
*/
|
|
337
|
+
export function knowledgeRepoKey(repo: RepoTarget): string {
|
|
338
|
+
return githubRepo(repo.cloneUrl) ?? repo.name;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export type ExtendTurnLimitResult =
|
|
342
|
+
| { kind: "extended"; runId: string; maxTurns: number }
|
|
343
|
+
| { kind: "not-increase"; runId: string; maxTurns: number }
|
|
344
|
+
| { kind: "not-active" };
|
|
345
|
+
|
|
346
|
+
export interface TurnLimitController {
|
|
347
|
+
maxTurns(): number;
|
|
348
|
+
close(): void;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export interface TurnLimitRegistry {
|
|
352
|
+
open(project: string, issue: number, runId: string, maxTurns: number): TurnLimitController;
|
|
353
|
+
extend(project: string, issue: number, maxTurns: number): ExtendTurnLimitResult;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export type WorkerControlResult =
|
|
357
|
+
| { kind: "ok"; runId: string; phase: WorkerPausePhase }
|
|
358
|
+
| { kind: "stopped"; runId: string; reason: string }
|
|
359
|
+
| { kind: "refused"; runId: string; error: string }
|
|
360
|
+
| { kind: "not-active" };
|
|
361
|
+
|
|
362
|
+
export interface WorkerControlSlot {
|
|
363
|
+
install(control: WorkerPauseControl): void;
|
|
364
|
+
/** Stop accepted before the session controller exists. */
|
|
365
|
+
requestedStop(): string | undefined;
|
|
366
|
+
close(): void;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export interface WorkerControlRegistry {
|
|
370
|
+
/**
|
|
371
|
+
* `onPhase` is called after a pause/resume/stop that actually changed this
|
|
372
|
+
* worker's phase (#842) — the authoritative transition, from the same control
|
|
373
|
+
* that performed it. It exists so a surface outside this registry (the run's
|
|
374
|
+
* Herdr representation) can follow the phase without polling and without
|
|
375
|
+
* inventing a second notion of "paused".
|
|
376
|
+
*/
|
|
377
|
+
open(
|
|
378
|
+
project: string,
|
|
379
|
+
issue: number,
|
|
380
|
+
runId: string,
|
|
381
|
+
onPhase?: (phase: WorkerPausePhase) => void,
|
|
382
|
+
): WorkerControlSlot;
|
|
383
|
+
/** `source` names who asked (board/cli/dashboard) — recorded as pause
|
|
384
|
+
* provenance so "who paused this and when" is answerable later (#997). */
|
|
385
|
+
pause(project: string, issue: number, source: string): Promise<WorkerControlResult>;
|
|
386
|
+
resume(project: string, issue: number): WorkerControlResult;
|
|
387
|
+
stop(project: string, issue: number, reason: string): Promise<WorkerControlResult>;
|
|
388
|
+
/** Live runs whose phase is not `running` — what /healthz and the board
|
|
389
|
+
* show — each carrying its pause provenance when one was recorded (#997). */
|
|
390
|
+
snapshot(project: string): {
|
|
391
|
+
issue: number;
|
|
392
|
+
runId: string;
|
|
393
|
+
phase: WorkerPausePhase;
|
|
394
|
+
source?: string;
|
|
395
|
+
pausedAtMs?: number;
|
|
396
|
+
}[];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// =============================================== daemon-owned session accounting
|
|
400
|
+
// (Phase 4, attribution.) Grooming and adjudication are real sessions the daemon
|
|
401
|
+
// launches on its own initiative: they resolve a model, take turns, and cost
|
|
402
|
+
// money. Until now their `{turns, spendUsd}` was printed to the journal and
|
|
403
|
+
// thrown away, so the only spend conductor could attribute was worker spend, and
|
|
404
|
+
// every question of the form "what did the pipeline cost, not just the workers"
|
|
405
|
+
// had no answer at all.
|
|
406
|
+
//
|
|
407
|
+
// They are NOT run rows. `computeStats` walks `runs` as issue journeys keyed on
|
|
408
|
+
// `run.issue`, deciding merged/failed/settled from the sequence of states for
|
|
409
|
+
// one issue — a groom row landing in that table would be counted as an attempt
|
|
410
|
+
// on the issue and would corrupt the merge counts it feeds. Hence a dedicated
|
|
411
|
+
// role-tagged table (Lane A's `recordSessionSpend`).
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Persist one daemon-owned session's accounting, telling unmetered from free.
|
|
415
|
+
*
|
|
416
|
+
* The honesty rule this exists to enforce: the harness reports `spendUsd: 0`
|
|
417
|
+
* both for a session that genuinely cost nothing and for one whose provider
|
|
418
|
+
* reports no per-request price at all (a subscription credential). Writing 0 for
|
|
419
|
+
* the second would render as "$0.00" and read as free, which is exactly the
|
|
420
|
+
* mistake `spend-telemetry.ts` was written to stop — "$0.00 spend is not proof
|
|
421
|
+
* of no spend" (#46, #984).
|
|
422
|
+
*
|
|
423
|
+
* So the discrimination is the same one {@link judgeSpendTelemetry} makes: a
|
|
424
|
+
* session that took turns is a *working* session, and a working session
|
|
425
|
+
* reporting zero is UNMETERED — the column is omitted and the stats lane renders
|
|
426
|
+
* it as unknown. A session that took no turns did not run (an unresolvable role,
|
|
427
|
+
* a harness that would not start), and zero is then the true cost.
|
|
428
|
+
*
|
|
429
|
+
* Recorded before any of the caller's verdict branching, so every early return
|
|
430
|
+
* still accounts for the money that was already spent.
|
|
431
|
+
*/
|
|
432
|
+
export function recordDaemonSessionSpend(
|
|
433
|
+
d: Pick<Deps, "project" | "store">,
|
|
434
|
+
row: {
|
|
435
|
+
role: "groom" | "adjudicator";
|
|
436
|
+
issue?: number;
|
|
437
|
+
/** The role/model pattern conductor asked for. */
|
|
438
|
+
model?: string;
|
|
439
|
+
/** What actually ran it, when the session reported back. */
|
|
440
|
+
resolvedModel?: string;
|
|
441
|
+
turns: number;
|
|
442
|
+
spendUsd: number;
|
|
443
|
+
at: number;
|
|
444
|
+
},
|
|
445
|
+
): void {
|
|
446
|
+
const metered = row.turns === 0 || row.spendUsd > 0;
|
|
447
|
+
try {
|
|
448
|
+
d.store.recordSessionSpend({
|
|
449
|
+
project: d.project.name,
|
|
450
|
+
role: row.role,
|
|
451
|
+
...(row.issue === undefined ? {} : { issue: row.issue }),
|
|
452
|
+
...(row.model === undefined ? {} : { model: row.model }),
|
|
453
|
+
...(row.resolvedModel === undefined ? {} : { resolvedModel: row.resolvedModel }),
|
|
454
|
+
turns: row.turns,
|
|
455
|
+
...(metered ? { spendUsd: row.spendUsd } : {}),
|
|
456
|
+
at: row.at,
|
|
457
|
+
});
|
|
458
|
+
} catch (err) {
|
|
459
|
+
// Accounting must never lose the session it accounts for: the verdict this
|
|
460
|
+
// run produced is worth more than its price tag.
|
|
461
|
+
log(`${row.role} session spend not recorded: ${errText(err)}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The terminal classes that mean "the transcript this round would resume
|
|
467
|
+
* cannot usefully take another turn".
|
|
468
|
+
*
|
|
469
|
+
* Enumerated rather than inferred: each of these is a *cap or a provider
|
|
470
|
+
* refusal*, not a logic failure, so resuming buys a session that dies the same
|
|
471
|
+
* way. The value is the clause the log line and the correction brief read, so
|
|
472
|
+
* the reason a fresh session exists is never "the daemon decided so".
|
|
473
|
+
*/
|
|
474
|
+
export const EXHAUSTED_SESSION_CLASSES: Partial<Record<FailureClass, string>> = {
|
|
475
|
+
"turn-cap-progress": "it was killed by the turns cap",
|
|
476
|
+
"turn-cap-spinning": "it was killed by the turns cap while spinning",
|
|
477
|
+
"wall-clock-cap-progress": "it was killed by the wall-clock cap",
|
|
478
|
+
"wall-clock-cap-spinning": "it was killed by the wall-clock cap while spinning",
|
|
479
|
+
"model-empty-stop": "the provider returned empty turns until the harness retry cap ended it",
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Why the session recorded on this run cannot be resumed, or `undefined` when
|
|
484
|
+
* it can (#1047).
|
|
485
|
+
*
|
|
486
|
+
* Two facts, both durable on the row and both readable without opening a
|
|
487
|
+
* transcript:
|
|
488
|
+
*
|
|
489
|
+
* - the row's own terminal classification, when it names a cap or the
|
|
490
|
+
* provider empty-stop; and
|
|
491
|
+
* - the turn ledger: a session at or past its recorded ceiling has no turns
|
|
492
|
+
* left whatever ended it. This is the fact that catches the rounds #1035
|
|
493
|
+
* actually burnt — the review path raises the ceiling by one round's
|
|
494
|
+
* allowance per round (#747), so a round that spends its whole allowance
|
|
495
|
+
* leaves `turns >= maxTurns` on the row even after the claim has cleared
|
|
496
|
+
* the classification.
|
|
497
|
+
*
|
|
498
|
+
* Shared with the answered-block continuation path, which needs the same
|
|
499
|
+
* question answered about the same kind of row: resuming an exhausted session
|
|
500
|
+
* is exactly as useless there.
|
|
501
|
+
*/
|
|
502
|
+
export function exhaustedSessionReason(
|
|
503
|
+
run: Pick<RunRecord, "failureClass" | "turns" | "maxTurns">,
|
|
504
|
+
): string | undefined {
|
|
505
|
+
const classed = run.failureClass === undefined ? undefined : EXHAUSTED_SESSION_CLASSES[run.failureClass];
|
|
506
|
+
if (classed !== undefined) return `${classed} (${run.failureClass})`;
|
|
507
|
+
if (run.turns >= run.maxTurns) {
|
|
508
|
+
return `it has spent its whole turn ceiling (${run.turns}/${run.maxTurns} turns)`;
|
|
509
|
+
}
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Canonical `owner/repo` identity from a configured network clone URL. */
|
|
514
|
+
export function githubRepo(cloneUrl: string): string | undefined {
|
|
515
|
+
const normalized = cloneUrl.replace(/\/$/, "").replace(/\.git$/, "");
|
|
516
|
+
const match = /^(?:https?:\/\/[^/]+\/|ssh:\/\/git@[^/]+\/|git@[^:]+:)([^/\s]+\/[^/\s]+)$/.exec(
|
|
517
|
+
normalized,
|
|
518
|
+
);
|
|
519
|
+
return match?.[1];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export interface RetainedCleanupCursor {
|
|
523
|
+
next: number;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** A provider refusal a session recorded before dying, or undefined for none. */
|
|
527
|
+
export interface SessionError {
|
|
528
|
+
status?: number;
|
|
529
|
+
message: string;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export function completionLastError(
|
|
533
|
+
providerCredit: string | undefined,
|
|
534
|
+
providerTransient: string | undefined,
|
|
535
|
+
verifiedReason: string | undefined,
|
|
536
|
+
sessionErr: SessionError | undefined,
|
|
537
|
+
): string | undefined {
|
|
538
|
+
return providerCredit ?? providerTransient ?? verifiedReason ?? sessionErr?.message;
|
|
539
|
+
}
|