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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `drain` — start, inspect, or cancel the project's self-expiring admission fence (#484).
|
|
3
|
+
*
|
|
4
|
+
* The bounded drain state machine landed in #776 (`createDrain`/`readDrain`/
|
|
5
|
+
* `cancelDrain` on daemon.ts, re-exported from fleet.ts); this verb is the
|
|
6
|
+
* operator surface over it. A drain is the release-window sibling of `hold`:
|
|
7
|
+
* the same admission boundary — settlement above it, nothing claimed below —
|
|
8
|
+
* but recorded with an absolute deadline, so admission resumes on its own even
|
|
9
|
+
* if the orchestrator dies. The only state it touches is the project's drain
|
|
10
|
+
* record: never the pause sentinel, the arm marker, or any queue label.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CommandContext } from "./context.ts";
|
|
14
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
15
|
+
import { statusSnapshot } from "../daemon.ts";
|
|
16
|
+
import { cancelDrain, createDrain, readDrain } from "../fleet.ts";
|
|
17
|
+
import { dim, ok } from "../ui/style.ts";
|
|
18
|
+
|
|
19
|
+
const DRAIN_USAGE = `omp-conductor drain — start, inspect, or cancel the project's admission drain.
|
|
20
|
+
|
|
21
|
+
usage:
|
|
22
|
+
omp-conductor drain start --until ISO|DURATION [--reason TEXT] [--project NAME]
|
|
23
|
+
omp-conductor drain status [--project NAME]
|
|
24
|
+
omp-conductor drain cancel [--project NAME]
|
|
25
|
+
|
|
26
|
+
A drain is a durable, self-expiring admission fence: new claims pause while
|
|
27
|
+
existing runs settle, and admission resumes automatically at the absolute
|
|
28
|
+
deadline — even if the orchestrator crashes. start replaces any prior drain of
|
|
29
|
+
the project; --until takes an ISO instant or a relative duration (90s, 45m,
|
|
30
|
+
2h, 1d) that must be bounded and in the future. status reports the active
|
|
31
|
+
drain's creation time, absolute expiry, reason, and remaining active runs.
|
|
32
|
+
cancel removes the project's drain and is idempotent. A drain never touches
|
|
33
|
+
the pause sentinel, the arm marker, or any queue label — it is the file record
|
|
34
|
+
that expires on its own.`;
|
|
35
|
+
|
|
36
|
+
/** The flags each drain subcommand accepts, after the subcommand itself. */
|
|
37
|
+
const DRAIN_FLAGS: Readonly<Record<string, readonly string[]>> = {
|
|
38
|
+
start: ["--project", "--until", "--reason"],
|
|
39
|
+
status: ["--project"],
|
|
40
|
+
cancel: ["--project"],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Relative durations accepted by `drain start --until`. */
|
|
44
|
+
const DURATION_RE = /^(\d+)([smhd])$/;
|
|
45
|
+
const DURATION_UNIT_MS: Readonly<Record<string, number>> = {
|
|
46
|
+
s: 1_000,
|
|
47
|
+
m: 60_000,
|
|
48
|
+
h: 3_600_000,
|
|
49
|
+
d: 86_400_000,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `--until` value → epoch-ms deadline, or undefined when the input is not a
|
|
54
|
+
* bounded expiry. A duration is relative to now; an ISO instant is absolute.
|
|
55
|
+
* Both forms must land on a finite safe-integer timestamp — the same bound
|
|
56
|
+
* `createDrain` persists as an absolute ISO instant, so a value beyond the
|
|
57
|
+
* Date range (e.g. 99999999999999999999d) is refused here instead of blowing
|
|
58
|
+
* up inside the record write. The absolute form must look like a calendar
|
|
59
|
+
* date: a bare number such as `3000` parses as a year in some engines, and an
|
|
60
|
+
* operator typing that almost certainly meant a duration.
|
|
61
|
+
*/
|
|
62
|
+
function parseExpiry(raw: string, now: number): number | undefined {
|
|
63
|
+
const duration = DURATION_RE.exec(raw);
|
|
64
|
+
if (duration !== null) {
|
|
65
|
+
const expiresAt = now + Number.parseInt(duration[1]!, 10) * DURATION_UNIT_MS[duration[2]!]!;
|
|
66
|
+
return Number.isSafeInteger(expiresAt) ? expiresAt : undefined;
|
|
67
|
+
}
|
|
68
|
+
if (!/^\d{4}-\d{2}-\d{2}/.test(raw)) return undefined;
|
|
69
|
+
const absolute = Date.parse(raw);
|
|
70
|
+
return Number.isSafeInteger(absolute) ? absolute : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rejects trailing tokens a drain subcommand does not declare, so a typo'd
|
|
75
|
+
* flag cannot be silently ignored — the same exit-2 scan `intake` and `stats`
|
|
76
|
+
* run. Both `--flag VALUE` and `--flag=VALUE` are accepted, matching the
|
|
77
|
+
* shared `flag()` parser.
|
|
78
|
+
*/
|
|
79
|
+
function assertKnownArgs(ctx: CommandContext, sub: string): void {
|
|
80
|
+
const allowed = DRAIN_FLAGS[sub] ?? [];
|
|
81
|
+
for (let i = 2; i < ctx.argv.length; i++) {
|
|
82
|
+
const token = ctx.argv[i];
|
|
83
|
+
if (token === undefined) continue;
|
|
84
|
+
const eq = token.startsWith("--") ? token.indexOf("=") : -1;
|
|
85
|
+
const name = eq < 0 ? token : token.slice(0, eq);
|
|
86
|
+
if (allowed.includes(name)) {
|
|
87
|
+
if (eq < 0) i += 1; // consume the flag's value token
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
process.stderr.write(`omp-conductor: drain ${sub}: unexpected argument "${token}"\n`);
|
|
91
|
+
process.exit(2);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function drainCommand(ctx: CommandContext): Promise<void> {
|
|
96
|
+
const sub = ctx.argv[1];
|
|
97
|
+
if (sub === "--help" || sub === "-h") {
|
|
98
|
+
process.stdout.write(DRAIN_USAGE);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (sub !== "start" && sub !== "status" && sub !== "cancel") {
|
|
102
|
+
process.stderr.write("omp-conductor: drain needs start, status, or cancel\n");
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
assertKnownArgs(ctx, sub);
|
|
106
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
107
|
+
|
|
108
|
+
if (sub === "start") {
|
|
109
|
+
const rawUntil = ctx.flag("until");
|
|
110
|
+
if (rawUntil === undefined || rawUntil.length === 0) {
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
"omp-conductor: drain start needs --until with an ISO instant or a duration (90s, 45m, 2h, 1d)\n",
|
|
113
|
+
);
|
|
114
|
+
process.exit(2);
|
|
115
|
+
}
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const expiresAt = parseExpiry(rawUntil, now);
|
|
118
|
+
if (expiresAt === undefined) {
|
|
119
|
+
process.stderr.write(
|
|
120
|
+
`omp-conductor: drain start: "${rawUntil}" is not a bounded expiry — ` +
|
|
121
|
+
"use an ISO instant (e.g. 2026-08-20T10:00:00Z) or a duration (90s, 45m, 2h, 1d)\n",
|
|
122
|
+
);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
if (expiresAt <= now) {
|
|
126
|
+
process.stderr.write(`omp-conductor: drain start: --until "${rawUntil}" must be in the future\n`);
|
|
127
|
+
process.exit(2);
|
|
128
|
+
}
|
|
129
|
+
const rawReason = ctx.flag("reason")?.trim().replace(/\s+/g, " ");
|
|
130
|
+
if (rawReason !== undefined && (rawReason === "" || rawReason.length > 500)) {
|
|
131
|
+
process.stderr.write("omp-conductor: drain start --reason needs 1-500 characters\n");
|
|
132
|
+
process.exit(2);
|
|
133
|
+
}
|
|
134
|
+
createDrain(project.name, {
|
|
135
|
+
expiresAt,
|
|
136
|
+
...(rawReason === undefined ? {} : { reason: rawReason }),
|
|
137
|
+
});
|
|
138
|
+
process.stdout.write(
|
|
139
|
+
ok(
|
|
140
|
+
`drain started for ${project.name} — claiming paused until ${new Date(expiresAt).toISOString()}` +
|
|
141
|
+
(rawReason === undefined ? "" : ` (${rawReason})`),
|
|
142
|
+
) + "\n",
|
|
143
|
+
);
|
|
144
|
+
process.stdout.write(
|
|
145
|
+
dim(
|
|
146
|
+
"the drain is a durable record: it survives crashes and resumes admission at the deadline on its own",
|
|
147
|
+
) + "\n",
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (sub === "status") {
|
|
153
|
+
const drain = statusSnapshot(project.name).drain;
|
|
154
|
+
if (drain === undefined) {
|
|
155
|
+
process.stdout.write(`drain: ${project.name} inactive — admission is open\n`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const lines = [
|
|
159
|
+
`drain: ${project.name} active`,
|
|
160
|
+
` since ${new Date(drain.since).toISOString()}`,
|
|
161
|
+
` expires at ${new Date(drain.expiresAt).toISOString()}`,
|
|
162
|
+
...(drain.reason === undefined ? [] : [` reason ${drain.reason}`]),
|
|
163
|
+
` remaining ${drain.remainingRuns} active run${drain.remainingRuns === 1 ? "" : "s"}`,
|
|
164
|
+
];
|
|
165
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const had = readDrain(project.name);
|
|
170
|
+
cancelDrain(project.name);
|
|
171
|
+
process.stdout.write(
|
|
172
|
+
had.kind === "active"
|
|
173
|
+
? ok(`drain cancelled for ${project.name} — admission resumes on the next dispatch pass`) + "\n"
|
|
174
|
+
: `drain: ${project.name} had no active drain — nothing to cancel\n`,
|
|
175
|
+
);
|
|
176
|
+
}
|
package/src/commands/extend.ts
CHANGED
|
@@ -2,25 +2,21 @@
|
|
|
2
2
|
* `extend` — raise a live run's turn ceiling, or set a bounded one-shot ceiling after a failed, killed, orphaned or blocked run.
|
|
3
3
|
*
|
|
4
4
|
* Moved out of cli.ts's switch by the per-verb module split (#462);
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* the daemon discovery resolves through {@link requireDaemonControl}, the
|
|
6
|
+
* same run-control target `worker` uses, so a live systemd-run daemon
|
|
7
|
+
* stays reachable when its pidfile is missing (#811) and the refusal
|
|
8
|
+
* answers cannot drift between the two verbs.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import type { CommandContext } from "./context.ts";
|
|
10
12
|
import { findProject, loadConfig } from "../config.ts";
|
|
11
|
-
import {
|
|
13
|
+
import { requireDaemonControl } from "../lifecycle.ts";
|
|
12
14
|
|
|
13
15
|
export async function extendCommand(ctx: CommandContext): Promise<void> {
|
|
14
16
|
const issue = ctx.issueArg("extend", ctx.argv[1]);
|
|
15
17
|
const maxTurns = ctx.turnsFlag();
|
|
16
18
|
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
17
|
-
const daemon =
|
|
18
|
-
if (daemon === undefined) throw new Error("daemon is not running");
|
|
19
|
-
if (daemon.project !== undefined && daemon.project !== project.name) {
|
|
20
|
-
throw new Error(
|
|
21
|
-
`daemon serves project "${daemon.project}", not requested project "${project.name}"`,
|
|
22
|
-
);
|
|
23
|
-
}
|
|
19
|
+
const daemon = await requireDaemonControl(project.name);
|
|
24
20
|
const response = await fetch(
|
|
25
21
|
`http://127.0.0.1:${daemon.port}/runs/${issue}/turn-limit`,
|
|
26
22
|
{
|
package/src/commands/status.ts
CHANGED
|
@@ -47,7 +47,6 @@ function styleStatus(text: string): string {
|
|
|
47
47
|
.map((line) => {
|
|
48
48
|
if (
|
|
49
49
|
line === "caps" ||
|
|
50
|
-
line === "daemon" ||
|
|
51
50
|
line === "active runs" ||
|
|
52
51
|
line === "active runs (none)" ||
|
|
53
52
|
line.startsWith("project ")
|
|
@@ -56,6 +55,11 @@ function styleStatus(text: string): string {
|
|
|
56
55
|
if (line.includes("STALLED")) return fail(line);
|
|
57
56
|
if (/^(dispatch|ticks|pane|herdr|telegram| healthz)\s+.*\b(running|healthy|ok)\b/.test(line))
|
|
58
57
|
return ok(line);
|
|
58
|
+
// The daemon headline carries its own three states (#685): green while
|
|
59
|
+
// serving, amber when the probe timed out or the pid is gone — a bare
|
|
60
|
+
// `daemon` heading line no longer exists to style.
|
|
61
|
+
if (/^daemon\s+.*\b(not running|unresponsive)\b/.test(line)) return warn(line);
|
|
62
|
+
if (/^daemon\s+running\b/.test(line)) return ok(line);
|
|
59
63
|
if (/^(dispatch|ticks|daemon)\s+.*\b(paused|stopped|not running|overdue)\b/.test(line))
|
|
60
64
|
return warn(line);
|
|
61
65
|
return line;
|
package/src/commands/watch.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import type { CommandContext } from "./context.ts";
|
|
22
22
|
import { findProject, loadConfig } from "../config.ts";
|
|
23
23
|
import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
|
|
24
|
+
import { shellQuote } from "../shell.ts";
|
|
24
25
|
import { dbPath, openStore } from "../store.ts";
|
|
25
26
|
|
|
26
27
|
const WATCH_USAGE = `omp-conductor watch — set a condition or carry note for the orchestrator itself.
|
|
@@ -36,6 +37,44 @@ one with a recorded reason — the verb that creates a watch is the verb that
|
|
|
36
37
|
ends it. A watch whose PR condition can no longer be observed (the PR merged
|
|
37
38
|
or closed first) is withdrawn by the daemon itself.`;
|
|
38
39
|
|
|
40
|
+
/** The flags each watch subcommand accepts, after the subcommand itself.
|
|
41
|
+
* `add`'s positional note and `withdraw`'s positional id are validated by
|
|
42
|
+
* the branches below, not through this table. */
|
|
43
|
+
const WATCH_FLAGS: Readonly<Record<string, readonly string[]>> = {
|
|
44
|
+
add: ["--blocks", "--note", "--project", "--resolves-when"],
|
|
45
|
+
list: ["--json", "--project"],
|
|
46
|
+
withdraw: ["--project", "--reason"],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Flags that take no value — `list --json` must not consume the token after it. */
|
|
50
|
+
const WATCH_BOOL_FLAGS: Readonly<Record<string, true>> = { "--json": true };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Rejects any token after the subcommand that its surface does not declare,
|
|
54
|
+
* so a typo'd flag can never be silently ignored — the defect where
|
|
55
|
+
* `--condition` created a watch with no condition at all. Both `--flag VALUE`
|
|
56
|
+
* and `--flag=VALUE` are accepted, matching the shared `flag()` parser; a
|
|
57
|
+
* value flag consumes the next token, a `--flag=VALUE` form or a boolean flag
|
|
58
|
+
* never does. The same exit-2 scan `drain` and `stats` run (#462).
|
|
59
|
+
*/
|
|
60
|
+
function assertKnownWatchArgs(ctx: CommandContext, sub: string, from: number): void {
|
|
61
|
+
const allowed = WATCH_FLAGS[sub] ?? [];
|
|
62
|
+
for (let i = from; i < ctx.argv.length; i++) {
|
|
63
|
+
const token = ctx.argv[i];
|
|
64
|
+
if (token === undefined) continue;
|
|
65
|
+
const eq = token.startsWith("--") ? token.indexOf("=") : -1;
|
|
66
|
+
const name = eq < 0 ? token : token.slice(0, eq);
|
|
67
|
+
if (allowed.includes(name)) {
|
|
68
|
+
if (eq < 0 && WATCH_BOOL_FLAGS[name] !== true) i += 1; // consume the flag's value token
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
process.stderr.write(
|
|
72
|
+
`omp-conductor: watch ${sub}: unexpected argument "${token}" (known: ${allowed.join(" ")}) — nothing was recorded\n`,
|
|
73
|
+
);
|
|
74
|
+
process.exit(2);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
39
78
|
export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
40
79
|
const sub = ctx.argv[1];
|
|
41
80
|
if (sub === "--help" || sub === "-h") {
|
|
@@ -46,6 +85,7 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
|
46
85
|
const store = openStore(dbPath());
|
|
47
86
|
try {
|
|
48
87
|
if (sub === "add") {
|
|
88
|
+
assertKnownWatchArgs(ctx, "add", 2);
|
|
49
89
|
const note = ctx.flag("note")?.trim();
|
|
50
90
|
if (note === undefined || note.length === 0 || note.startsWith("--")) {
|
|
51
91
|
process.stderr.write("omp-conductor: watch add needs --note with what the next tick should know\n");
|
|
@@ -68,13 +108,17 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
|
68
108
|
at: Date.now(),
|
|
69
109
|
});
|
|
70
110
|
const wake = condition === undefined ? "read by the next tick" : "the daemon wakes the next tick when it is met";
|
|
111
|
+
// The verb that ends the row names the project it was created in: this
|
|
112
|
+
// command is copied, and on a host with several projects the bare form is
|
|
113
|
+
// ambiguous (#810).
|
|
71
114
|
process.stdout.write(
|
|
72
|
-
`watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id}\n`,
|
|
115
|
+
`watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id} --project ${shellQuote(project.name)}\n`,
|
|
73
116
|
);
|
|
74
117
|
return;
|
|
75
118
|
}
|
|
76
119
|
|
|
77
120
|
if (sub === "list" || sub === undefined) {
|
|
121
|
+
assertKnownWatchArgs(ctx, "list", 2);
|
|
78
122
|
const open = store.openDecisions(project.name).filter((d) => d.kind === "watch");
|
|
79
123
|
const now = Date.now();
|
|
80
124
|
const watches = open.map((d) => ({
|
|
@@ -95,13 +139,17 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
|
95
139
|
for (const watch of watches) {
|
|
96
140
|
process.stdout.write(
|
|
97
141
|
`${watch.id} ${watch.ageHours}h blocks:${watch.blocks ?? "-"} ` +
|
|
98
|
-
|
|
142
|
+
// The listing already resolved the project, so the cleanup command
|
|
143
|
+
// it displays must carry it: an operator copying a command from
|
|
144
|
+
// this output should get one that runs as written (#810).
|
|
145
|
+
`condition:${watch.condition ?? "-"} ${watch.note} (end: omp-conductor watch withdraw ${watch.id} --project ${shellQuote(project.name)})\n`,
|
|
99
146
|
);
|
|
100
147
|
}
|
|
101
148
|
return;
|
|
102
149
|
}
|
|
103
150
|
|
|
104
151
|
if (sub === "withdraw") {
|
|
152
|
+
assertKnownWatchArgs(ctx, "withdraw", 3);
|
|
105
153
|
const id = ctx.argv[2];
|
|
106
154
|
if (id === undefined || id.startsWith("--")) {
|
|
107
155
|
process.stderr.write("omp-conductor: watch withdraw needs the watch id\n");
|
package/src/commands/worker.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* `worker` — pause/resume/stop one live worker session through the daemon's run-control HTTP endpoints.
|
|
3
3
|
*
|
|
4
4
|
* Moved out of cli.ts's switch by the per-verb module split (#462);
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* the daemon discovery resolves through {@link requireDaemonControl} so a
|
|
6
|
+
* live systemd-run daemon stays reachable when its pidfile is missing (#811).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
10
|
import { findProject, loadConfig } from "../config.ts";
|
|
11
|
-
import {
|
|
11
|
+
import { requireDaemonControl } from "../lifecycle.ts";
|
|
12
12
|
|
|
13
13
|
export async function workerCommand(ctx: CommandContext): Promise<void> {
|
|
14
14
|
const sub = ctx.argv[1];
|
|
@@ -28,13 +28,12 @@ if (sub === "stop" && (reason === undefined || reason === "" || reason.length >
|
|
|
28
28
|
process.exit(2);
|
|
29
29
|
}
|
|
30
30
|
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
31
|
+
// The pidfile is authoritative when it names a live process; a missing or
|
|
32
|
+
// stale record falls back to the systemd unit, proved through its own
|
|
33
|
+
// /healthz answer, so a live unit-owned daemon is never reported stopped
|
|
34
|
+
// because the runtime directory was wiped (#811). The refusal chain is
|
|
35
|
+
// shared with `extend` through requireDaemonControl.
|
|
36
|
+
const daemon = await requireDaemonControl(project.name);
|
|
38
37
|
const response = await fetch(
|
|
39
38
|
`http://127.0.0.1:${daemon.port}/runs/${issue}/${sub}`,
|
|
40
39
|
{
|
package/src/config-schema.ts
CHANGED
|
@@ -336,6 +336,25 @@ const stateLabelsSchema = z
|
|
|
336
336
|
})
|
|
337
337
|
.partial();
|
|
338
338
|
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Host constraints (#721)
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
const hostConstraintsSchema = z
|
|
344
|
+
.object({
|
|
345
|
+
description: z.string().min(1).describe("What this host is and what else it runs"),
|
|
346
|
+
path: z
|
|
347
|
+
.string()
|
|
348
|
+
.min(1)
|
|
349
|
+
.describe('The non-interactive PATH a script or `ssh host "<cmd>"` invocation must export'),
|
|
350
|
+
conventions: z
|
|
351
|
+
.record(z.string().min(1), z.string().min(1))
|
|
352
|
+
.describe("Per-repo command conventions, keyed by the brief's `owner/repo` slug"),
|
|
353
|
+
})
|
|
354
|
+
.strict()
|
|
355
|
+
.partial()
|
|
356
|
+
.describe("Operator-authored host facts rendered into every worker brief");
|
|
357
|
+
|
|
339
358
|
// ---------------------------------------------------------------------------
|
|
340
359
|
// Project / root
|
|
341
360
|
// ---------------------------------------------------------------------------
|
|
@@ -409,6 +428,11 @@ const configSchema = z
|
|
|
409
428
|
.min(1, "must name a directory")
|
|
410
429
|
.optional()
|
|
411
430
|
.describe("Absolute directory for restorable conductor.db snapshots; defaults to <stateDir>/backups/db"),
|
|
431
|
+
// Cores and RAM are derived by host.ts at render time; only what the
|
|
432
|
+
// operator must type belongs in this block.
|
|
433
|
+
host: hostConstraintsSchema
|
|
434
|
+
.optional()
|
|
435
|
+
.describe("Host facts every worker brief renders; absent renders no section"),
|
|
412
436
|
projects: z.array(projectSchema).min(1, `"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`),
|
|
413
437
|
})
|
|
414
438
|
.loose()
|
package/src/config.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
type Caps,
|
|
50
50
|
type ConductorConfig,
|
|
51
51
|
type DigestCadence,
|
|
52
|
+
type HostConstraints,
|
|
52
53
|
type InterruptCategory,
|
|
53
54
|
type MergePreconditions,
|
|
54
55
|
type PlanUsageCap,
|
|
@@ -1092,8 +1093,48 @@ function finalize(data: unknown, path: string): ConductorConfig {
|
|
|
1092
1093
|
});
|
|
1093
1094
|
}
|
|
1094
1095
|
|
|
1096
|
+
const rawHost = root["host"] as Raw | undefined;
|
|
1097
|
+
const host = finalizeHost(rawHost);
|
|
1098
|
+
const rawDbBackupDir = root["dbBackupDir"];
|
|
1099
|
+
const dbBackupDir = typeof rawDbBackupDir === "string" ? rawDbBackupDir : undefined;
|
|
1100
|
+
|
|
1095
1101
|
if (problems.length > 0) throw new Error(problemEnvelope(path, problems));
|
|
1096
|
-
|
|
1102
|
+
// `ConductorConfig` is rebuilt here, so every optional top-level key must be
|
|
1103
|
+
// spelled out or it silently vanishes from every loaded config. `dbBackupDir`
|
|
1104
|
+
// and `host` are the two today; a third needs its own line below (#773).
|
|
1105
|
+
return {
|
|
1106
|
+
version: CONFIG_VERSION,
|
|
1107
|
+
defaults,
|
|
1108
|
+
projects,
|
|
1109
|
+
...(dbBackupDir === undefined ? {} : { dbBackupDir }),
|
|
1110
|
+
...(host === undefined ? {} : { host }),
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* The typed host-constraints block (#721), or `undefined` when nothing usable
|
|
1116
|
+
* was configured — matching the schema's "absent renders no section" promise.
|
|
1117
|
+
* Trimming and empty-drop mirror the other hand-edited string fields; an
|
|
1118
|
+
* object whose every key is empty loads as if the field were absent.
|
|
1119
|
+
*/
|
|
1120
|
+
function finalizeHost(parsed: Raw | undefined): HostConstraints | undefined {
|
|
1121
|
+
if (parsed === undefined) return undefined;
|
|
1122
|
+
const out: HostConstraints = {};
|
|
1123
|
+
const description = parsed["description"];
|
|
1124
|
+
if (typeof description === "string" && description.trim() !== "") out.description = description.trim();
|
|
1125
|
+
const path = parsed["path"];
|
|
1126
|
+
if (typeof path === "string" && path.trim() !== "") out.path = path.trim();
|
|
1127
|
+
const conventions = parsed["conventions"];
|
|
1128
|
+
if (typeof conventions === "object" && conventions !== null && !Array.isArray(conventions)) {
|
|
1129
|
+
const kept: Record<string, string> = {};
|
|
1130
|
+
for (const [slug, value] of Object.entries(conventions as Record<string, unknown>)) {
|
|
1131
|
+
if (typeof value === "string" && value.trim() !== "" && slug.trim() !== "") {
|
|
1132
|
+
kept[slug.trim()] = value.trim();
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (Object.keys(kept).length > 0) out.conventions = kept;
|
|
1136
|
+
}
|
|
1137
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
1097
1138
|
}
|
|
1098
1139
|
|
|
1099
1140
|
function finalizeProject(
|