omp-conductor 0.20.1 → 0.20.3
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/package.json +1 -1
- package/schema/config.schema.json +24 -0
- package/src/commands/companion.ts +52 -16
- package/src/commands/daemon.ts +14 -25
- package/src/commands/drain.ts +12 -5
- package/src/commands/worker.ts +5 -0
- package/src/config-schema.ts +8 -0
- package/src/config.ts +17 -2
- package/src/daemon/drain.ts +33 -0
- package/src/daemon/http.ts +28 -0
- package/src/daemon/review.ts +11 -11
- package/src/daemon/supervision.ts +144 -1
- package/src/daemon/tick.ts +46 -15
- package/src/daemon/views.ts +17 -0
- package/src/daemon.ts +2 -1
- package/src/decisions.ts +20 -9
- package/src/diff-flags.ts +186 -31
- package/src/doctor.ts +320 -9
- package/src/escalate.ts +187 -15
- package/src/failure-class.ts +176 -4
- package/src/fleet.ts +444 -50
- package/src/graph-health.ts +17 -1
- package/src/groom.ts +11 -0
- package/src/knowledge.ts +75 -21
- package/src/lifecycle.ts +267 -5
- package/src/orchestrator-tick.ts +8 -2
- package/src/ready-gate.ts +100 -5
- package/src/reports.ts +4 -1
- package/src/settlement.ts +98 -7
- package/src/status-render.ts +27 -2
- package/src/store.ts +50 -13
- package/src/to-spec.ts +52 -0
- package/src/tracker/github.ts +132 -10
- package/src/types.ts +31 -0
- package/src/upgrade.ts +12 -5
- package/src/verbs/server.ts +16 -12
package/src/graph-health.ts
CHANGED
|
@@ -52,7 +52,10 @@ export type CodeGraphHealth =
|
|
|
52
52
|
active: "active" | "inactive" | "unknown";
|
|
53
53
|
};
|
|
54
54
|
refresh: {
|
|
55
|
-
|
|
55
|
+
/** "running": the oneshot is executing right now (#1090). systemd has
|
|
56
|
+
* already cleared the previous exit timestamp mid-run, so this is a
|
|
57
|
+
* progress report — never a claim about staleness or absence. */
|
|
58
|
+
result: "success" | "failed" | "running" | "unknown";
|
|
56
59
|
fresh: boolean | null;
|
|
57
60
|
lastSuccessAt?: string;
|
|
58
61
|
ageMs?: number;
|
|
@@ -208,6 +211,12 @@ function refreshResult(result: ReadOnlyCommandResult, now: number): RefreshResul
|
|
|
208
211
|
const serviceResult = fields.get("Result");
|
|
209
212
|
const exitStatus = fields.get("ExecMainStatus");
|
|
210
213
|
const completedAt = fields.get("ExecMainExitTimestamp");
|
|
214
|
+
// #1090: an empty ExecMainExitTimestamp says "no completed run yet", which
|
|
215
|
+
// is equally true of a unit that has never run and of one executing right
|
|
216
|
+
// now — systemd clears it when the next run starts. ActiveState comes back
|
|
217
|
+
// from the same single `systemctl show` and separates the two; evidence
|
|
218
|
+
// captured without the property keeps the conservative pre-#1090 reading.
|
|
219
|
+
const activeState = fields.get("ActiveState");
|
|
211
220
|
if (serviceResult === undefined || exitStatus === undefined || completedAt === undefined) {
|
|
212
221
|
return unknown("refresh service returned an unrecognized result", true);
|
|
213
222
|
}
|
|
@@ -219,6 +228,12 @@ function refreshResult(result: ReadOnlyCommandResult, now: number): RefreshResul
|
|
|
219
228
|
};
|
|
220
229
|
}
|
|
221
230
|
if (completedAt === "" || completedAt === "n/a") {
|
|
231
|
+
if (activeState === "activating" || activeState === "active") {
|
|
232
|
+
// There is no completed timestamp to measure staleness against yet, but
|
|
233
|
+
// this is progress, not absence: the reindex is keeping the graph fresh
|
|
234
|
+
// at this moment, so neither the row nor the previous message degrades.
|
|
235
|
+
return { health: { result: "running", fresh: null }, uncertain: false };
|
|
236
|
+
}
|
|
222
237
|
return unknown("no successful refresh has been recorded", false);
|
|
223
238
|
}
|
|
224
239
|
const completedMs = Date.parse(completedAt);
|
|
@@ -290,6 +305,7 @@ export async function probeCodeGraph(
|
|
|
290
305
|
"--property=Result",
|
|
291
306
|
"--property=ExecMainStatus",
|
|
292
307
|
"--property=ExecMainExitTimestamp",
|
|
308
|
+
"--property=ActiveState",
|
|
293
309
|
]),
|
|
294
310
|
]);
|
|
295
311
|
|
package/src/groom.ts
CHANGED
|
@@ -114,6 +114,17 @@ export interface ReadyGateRejection {
|
|
|
114
114
|
* {@link toSpecDurableVerdict} still recovers the verdict and the candidate is
|
|
115
115
|
* not re-groomed — the spec is rejected, not ungroomed.
|
|
116
116
|
*
|
|
117
|
+
* That durability is bounded by the verdict itself (#1060). A rejection
|
|
118
|
+
* beside a verdict the contract still accepts is not re-groomable, because
|
|
119
|
+
* the fault lies in the issue — missing criteria, a missing lane — and only
|
|
120
|
+
* an edit to the issue can fix it. A rejection beside a verdict the contract
|
|
121
|
+
* refuses — veltro#731's prose lane entry, persisted before parse-time lane
|
|
122
|
+
* validation — reads as not-durable through {@link toSpecDurableVerdict},
|
|
123
|
+
* and the candidate becomes re-groomable: no issue edit can ever satisfy a
|
|
124
|
+
* gate whose refusal is baked into the verdict, and complying with the
|
|
125
|
+
* refusal's remedy would write the unusable entry into the issue itself, so
|
|
126
|
+
* a fresh pass is the only repair.
|
|
127
|
+
*
|
|
117
128
|
* Fails closed on anything it cannot read: an empty `missing` array is not a
|
|
118
129
|
* rejection (a gate that found nothing missing passed), and a non-string entry
|
|
119
130
|
* would render as `undefined` in a digest line an operator is asked to act on.
|
package/src/knowledge.ts
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* accumulated knowledge is worse than one fewer fact, because a worker cannot
|
|
20
20
|
* tell a clipped warning from a complete one.
|
|
21
21
|
*
|
|
22
|
+
* What the cap never drops is structure: an operator distillation's headings,
|
|
23
|
+
* paragraphs and blank separators survive every append verbatim and in place —
|
|
24
|
+
* the facts rotate, the shape of the knowledge stays.
|
|
25
|
+
*
|
|
22
26
|
* Writes are best-effort. This is an optimisation, and a run must never fail
|
|
23
27
|
* because the overlay could not be written: the callers are settlement paths.
|
|
24
28
|
*/
|
|
@@ -74,31 +78,69 @@ function encodeRepoKey(repo: string): string {
|
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
81
|
+
* One line of the overlay document. An `entry` is a fleet-learned fact — the
|
|
82
|
+
* `- `-prefixed line the writer produces and attributes; anything else, a
|
|
83
|
+
* heading, a paragraph or a blank separator, is `structure`: hand-added
|
|
84
|
+
* scaffolding from an operator distillation. The distinction is the format:
|
|
85
|
+
* entries are appendable, deduplicated and evictable, structure is none of
|
|
86
|
+
* those things, and it survives every write verbatim and in place.
|
|
87
|
+
*/
|
|
88
|
+
type OverlayBlock = { kind: "entry" | "structure"; line: string };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The overlay document parsed into blocks, in file order. Lossless: joining
|
|
92
|
+
* the lines back with "\n" reproduces the input, which is what makes keeping
|
|
93
|
+
* the structure through a write a property of the parse rather than a hope.
|
|
79
94
|
*
|
|
80
95
|
* One parse, shared by the writer and the brief renderer on purpose: they must
|
|
81
96
|
* agree on what counts as an entry, or the cap one enforces is not the cap the
|
|
82
97
|
* other renders and a "16 KB" section arrives at 20.
|
|
83
98
|
*/
|
|
84
|
-
function
|
|
85
|
-
return
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
99
|
+
function blocksOf(text: string): OverlayBlock[] {
|
|
100
|
+
if (text === "") return [];
|
|
101
|
+
const lines = text.split("\n");
|
|
102
|
+
// The final newline is the file's terminator, not a blank last line: pop it
|
|
103
|
+
// here so appended entries join the document itself, and let renderDoc put
|
|
104
|
+
// exactly one back.
|
|
105
|
+
if (lines.at(-1) === "") lines.pop();
|
|
106
|
+
return lines.map((raw) => {
|
|
107
|
+
const line = raw.trimEnd();
|
|
108
|
+
return line.startsWith(ENTRY_PREFIX) && line.slice(ENTRY_PREFIX.length).trim() !== ""
|
|
109
|
+
? { kind: "entry", line }
|
|
110
|
+
: { kind: "structure", line: raw };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The document text for a block list: a blank run at the EOF seam is
|
|
116
|
+
* whitespace, not content, so it is trimmed and exactly one newline terminates
|
|
117
|
+
* the document — the shape every writer of this format has always produced.
|
|
118
|
+
* Interior blanks pass through untouched.
|
|
119
|
+
*/
|
|
120
|
+
function renderDoc(blocks: readonly OverlayBlock[]): string {
|
|
121
|
+
const kept = [...blocks];
|
|
122
|
+
while (kept.at(-1)?.kind === "structure" && kept.at(-1)?.line.trim() === "") kept.pop();
|
|
123
|
+
return `${kept.map((b) => b.line).join("\n")}\n`;
|
|
89
124
|
}
|
|
90
125
|
|
|
91
126
|
/**
|
|
92
|
-
* The newest
|
|
93
|
-
* first and only ever whole.
|
|
127
|
+
* The newest content that fits under {@link KNOWLEDGE_MAX_BYTES}, oldest
|
|
128
|
+
* entries dropped first and only ever whole.
|
|
129
|
+
*
|
|
130
|
+
* Only entries evict. A distilled file keeps its headings and paragraphs even
|
|
131
|
+
* when the facts beneath them have been rotated out, and when only non-entry
|
|
132
|
+
* text remains the document simply stays over the ceiling: operator prose is
|
|
133
|
+
* not ours to delete, and distilling it back under the cap is the remedy the
|
|
134
|
+
* format itself names.
|
|
94
135
|
*
|
|
95
136
|
* Byte lengths, not character counts: the cap is a context-budget promise, and
|
|
96
137
|
* one accented identifier or box-drawing character is several bytes.
|
|
97
138
|
*/
|
|
98
|
-
function withinCap(
|
|
99
|
-
const kept = [...
|
|
100
|
-
|
|
101
|
-
|
|
139
|
+
function withinCap(blocks: readonly OverlayBlock[]): OverlayBlock[] {
|
|
140
|
+
const kept = [...blocks];
|
|
141
|
+
const bytes = (): number => Buffer.byteLength(renderDoc(kept), "utf8");
|
|
142
|
+
while (kept.some((b) => b.kind === "entry") && bytes() > KNOWLEDGE_MAX_BYTES) {
|
|
143
|
+
kept.splice(kept.findIndex((b) => b.kind === "entry"), 1);
|
|
102
144
|
}
|
|
103
145
|
return kept;
|
|
104
146
|
}
|
|
@@ -139,6 +181,10 @@ export function readKnowledge(repo: string): string | undefined {
|
|
|
139
181
|
* `proofCommands` for every issue in a repo — cannot inflate the file until the
|
|
140
182
|
* cap evicts something real.
|
|
141
183
|
*
|
|
184
|
+
* The rewrite preserves every non-entry line the file carries — the headings,
|
|
185
|
+
* paragraphs and blank separators of an operator distillation — exactly where
|
|
186
|
+
* they were: entries rotate under the cap, structure does not.
|
|
187
|
+
*
|
|
142
188
|
* Best-effort by contract: the callers are settlement paths, and a failed write
|
|
143
189
|
* here must never turn a finished run into a failed one.
|
|
144
190
|
*/
|
|
@@ -157,25 +203,33 @@ export function appendKnowledge(
|
|
|
157
203
|
if (rendered.length === 0) return;
|
|
158
204
|
const path = knowledgePath(repo);
|
|
159
205
|
try {
|
|
160
|
-
const existing =
|
|
161
|
-
// Dynamic membership over the file's own
|
|
206
|
+
const existing = blocksOf(readKnowledge(repo) ?? "");
|
|
207
|
+
// Dynamic membership over the file's own entries, grown as this batch is
|
|
162
208
|
// folded in, so one call cannot append the same fact twice either.
|
|
163
|
-
const seen = new Set(existing);
|
|
164
|
-
const added:
|
|
209
|
+
const seen = new Set(existing.filter((b) => b.kind === "entry").map((b) => b.line));
|
|
210
|
+
const added: OverlayBlock[] = [];
|
|
165
211
|
for (const line of rendered) {
|
|
166
212
|
if (seen.has(line)) continue;
|
|
167
213
|
seen.add(line);
|
|
168
|
-
added.push(line);
|
|
214
|
+
added.push({ kind: "entry", line });
|
|
169
215
|
}
|
|
170
216
|
if (added.length === 0) return;
|
|
171
|
-
|
|
217
|
+
// A batch appended under a distilled paragraph or heading takes one blank
|
|
218
|
+
// separator, so it never glues onto the prose above it; after an entry or
|
|
219
|
+
// a blank none is needed.
|
|
220
|
+
const tail = existing.at(-1);
|
|
221
|
+
const gap: OverlayBlock[] =
|
|
222
|
+
tail !== undefined && tail.kind === "structure" && tail.line.trim() !== ""
|
|
223
|
+
? [{ kind: "structure", line: "" }]
|
|
224
|
+
: [];
|
|
225
|
+
const kept = withinCap([...existing, ...gap, ...added]);
|
|
172
226
|
mkdirSync(dirname(path), { recursive: true });
|
|
173
227
|
// Atomic like every other durable conductor state file: a brief renderer
|
|
174
228
|
// reading while a settlement writes sees the old file or the new one, never
|
|
175
229
|
// a half-written entry.
|
|
176
230
|
const tmp = `${path}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`;
|
|
177
231
|
try {
|
|
178
|
-
writeFileSync(tmp,
|
|
232
|
+
writeFileSync(tmp, renderDoc(kept), "utf8");
|
|
179
233
|
renameSync(tmp, path);
|
|
180
234
|
} catch (err) {
|
|
181
235
|
rmSync(tmp, { force: true });
|
|
@@ -209,7 +263,7 @@ export function appendKnowledge(
|
|
|
209
263
|
export function knowledgeSection(repo: string): string {
|
|
210
264
|
const text = readKnowledge(repo);
|
|
211
265
|
if (text === undefined) return "";
|
|
212
|
-
const entries = withinCap(
|
|
266
|
+
const entries = withinCap(blocksOf(text)).filter((b) => b.kind === "entry").map((b) => b.line);
|
|
213
267
|
if (entries.length === 0) return "";
|
|
214
268
|
return [
|
|
215
269
|
KNOWLEDGE_HEADING,
|
package/src/lifecycle.ts
CHANGED
|
@@ -194,6 +194,103 @@ export function livingDaemon(): DaemonRecord | undefined {
|
|
|
194
194
|
return undefined;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* `DaemonRecord.logFile` for a daemon nobody spawned — which is how systemd
|
|
199
|
+
* runs it (`ExecStart … omp-conductor daemon`) and how a hand-run foreground
|
|
200
|
+
* `daemon` works too. The field is required and `status` prints it, so it has
|
|
201
|
+
* to say something true: a foreground daemon opened no log of its own —
|
|
202
|
+
* whoever started it owns its stdout, be that the journal, a terminal, or a
|
|
203
|
+
* pane.
|
|
204
|
+
*/
|
|
205
|
+
const FOREGROUND_LOG = "<inherited stdout — started in the foreground>";
|
|
206
|
+
|
|
207
|
+
/** What this process would publish as its own boot record, given its flags. */
|
|
208
|
+
function foregroundRecord(o: { port?: number; project?: string }): DaemonRecord {
|
|
209
|
+
return {
|
|
210
|
+
pid: process.pid,
|
|
211
|
+
port: o.port ?? DEFAULT_PORT,
|
|
212
|
+
startedAt: Date.now(),
|
|
213
|
+
logFile: FOREGROUND_LOG,
|
|
214
|
+
...(o.project === undefined ? {} : { project: o.project }),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type ForegroundBoot =
|
|
219
|
+
| { kind: "claimed"; record: DaemonRecord }
|
|
220
|
+
| { kind: "foreign-live"; pid: number };
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The boot half of pidfile ownership (#1114): a daemon that starts without a
|
|
224
|
+
* managed spawn — systemd's `ExecStart`, a reboot, a bare hand-run — publishes
|
|
225
|
+
* its own record here, so the single-owner guard (`startDaemon`'s live-record
|
|
226
|
+
* refusal), `wake`, `status` and orphan reconciliation are armed no matter who
|
|
227
|
+
* started the process.
|
|
228
|
+
*
|
|
229
|
+
* A foreign live record is refused, never stolen; a stale one is cleared by
|
|
230
|
+
* {@link livingDaemon} on the way past; and a record already naming this pid
|
|
231
|
+
* was written by whoever spawned us and knows the real log file — replacing
|
|
232
|
+
* it with a guess would be a downgrade.
|
|
233
|
+
*/
|
|
234
|
+
export function publishForegroundRecord(
|
|
235
|
+
o: { port?: number; project?: string } = {},
|
|
236
|
+
): ForegroundBoot {
|
|
237
|
+
const running = livingDaemon();
|
|
238
|
+
if (running !== undefined && running.pid !== process.pid) {
|
|
239
|
+
return { kind: "foreign-live", pid: running.pid };
|
|
240
|
+
}
|
|
241
|
+
if (running !== undefined) return { kind: "claimed", record: running };
|
|
242
|
+
const record = foregroundRecord(o);
|
|
243
|
+
writeRecord(record);
|
|
244
|
+
return { kind: "claimed", record };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The running half of pidfile ownership (#1114): the record can vanish under
|
|
249
|
+
* a perfectly healthy daemon — an external sweep of the shared runtime
|
|
250
|
+
* directory, a tmp cleaner — and nothing else re-arms the single-owner guard
|
|
251
|
+
* until the next restart. The daemon itself repairs that: absent or stale
|
|
252
|
+
* means it publishes again; a live foreign record is left alone (a `--once`
|
|
253
|
+
* drill may legitimately own visibility mid-tick).
|
|
254
|
+
*/
|
|
255
|
+
export function republishSelfRecord(o: { port?: number; project?: string } = {}): void {
|
|
256
|
+
const current = livingDaemon();
|
|
257
|
+
// Ours is already published, or a live foreign process owns visibility:
|
|
258
|
+
// either way there is nothing to repair.
|
|
259
|
+
if (current !== undefined) return;
|
|
260
|
+
writeRecord(foregroundRecord(o));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** How often a running daemon re-asserts its record ({@link republishSelfRecord}). */
|
|
264
|
+
export const RECORD_REPAIR_INTERVAL_MS = 60_000;
|
|
265
|
+
|
|
266
|
+
/** Stops the periodic repair started by {@link startRecordRepair}. */
|
|
267
|
+
export interface RecordRepair {
|
|
268
|
+
stop(): void;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Re-assert the record on an interval while the daemon runs. A failed repair
|
|
273
|
+
* never propagates: the pidfile is visibility, not correctness, and a failed
|
|
274
|
+
* attempt retries on the next tick rather than killing the dispatcher over it.
|
|
275
|
+
*/
|
|
276
|
+
export function startRecordRepair(
|
|
277
|
+
o: { port?: number; project?: string } = {},
|
|
278
|
+
intervalMs = RECORD_REPAIR_INTERVAL_MS,
|
|
279
|
+
): RecordRepair {
|
|
280
|
+
const timer = setInterval(() => {
|
|
281
|
+
try {
|
|
282
|
+
republishSelfRecord(o);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
process.stderr.write(
|
|
285
|
+
`omp-conductor: could not re-assert the daemon pidfile: ${(err as Error).message}\n`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}, intervalMs);
|
|
289
|
+
// Bookkeeping must never hold the event loop open at shutdown.
|
|
290
|
+
timer.unref?.();
|
|
291
|
+
return { stop: () => clearInterval(timer) };
|
|
292
|
+
}
|
|
293
|
+
|
|
197
294
|
/**
|
|
198
295
|
* The `daemon --once` lease file, one per process. Created `O_EXCL` so two
|
|
199
296
|
* simultaneous `--once` runs cannot both win ownership, and it names the
|
|
@@ -1186,6 +1283,10 @@ export type SystemctlResult = {
|
|
|
1186
1283
|
stderr: string;
|
|
1187
1284
|
/** Binary not found (ENOENT). Distinct from a failed invocation of a present binary. */
|
|
1188
1285
|
missing?: boolean;
|
|
1286
|
+
/** The client bound expired (spawnSync ETIMEDOUT). A state-changing verb
|
|
1287
|
+
* that reports this is re-judged from the unit's observed state before it
|
|
1288
|
+
* becomes a failure (#1112). */
|
|
1289
|
+
timedOut?: boolean;
|
|
1189
1290
|
};
|
|
1190
1291
|
|
|
1191
1292
|
export type SystemctlFn = (args: string[]) => SystemctlResult;
|
|
@@ -1251,14 +1352,169 @@ function testSystemctl(args: string[]): SystemctlResult {
|
|
|
1251
1352
|
}
|
|
1252
1353
|
}
|
|
1253
1354
|
|
|
1254
|
-
|
|
1255
|
-
|
|
1355
|
+
/** Verbs whose completion is a unit job: systemd holds the client until the
|
|
1356
|
+
* stop/start finishes, which legitimately takes as long as the unit's own
|
|
1357
|
+
* graceful-shutdown budget. Everything else answers from manager state and
|
|
1358
|
+
* keeps the short probe bound (#1112). */
|
|
1359
|
+
const STATE_CHANGING_VERBS: Record<string, true> = {
|
|
1360
|
+
start: true,
|
|
1361
|
+
stop: true,
|
|
1362
|
+
restart: true,
|
|
1363
|
+
"try-restart": true,
|
|
1364
|
+
reload: true,
|
|
1365
|
+
"reload-or-restart": true,
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Bound for probe verbs (`show`, `is-active`, `is-enabled`, …): a hung dbus
|
|
1370
|
+
* becomes `unknown` ownership instead of a hang. Callers must NOT treat that
|
|
1371
|
+
* timeout as "no unit". State-changing verbs get their own budget below — a
|
|
1372
|
+
* 15s bound on `restart` is what declared a healthy restart failed and rolled
|
|
1373
|
+
* a good release back (#1112).
|
|
1374
|
+
*/
|
|
1375
|
+
const SYSTEMCTL_PROBE_TIMEOUT_MS = 15_000;
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* systemd's built-in `TimeoutStopSec`: how long any unit without an explicit
|
|
1379
|
+
* override may legitimately spend stopping before systemd itself escalates.
|
|
1380
|
+
* A client bound for a state-changing verb never sits below this floor —
|
|
1381
|
+
* smaller is structurally able to lose against a healthy, graceful stop.
|
|
1382
|
+
*/
|
|
1383
|
+
const SYSTEMD_DEFAULT_TIMEOUT_STOP_MS = 90_000;
|
|
1384
|
+
|
|
1385
|
+
/** Slack over the unit's own stop budget: the start half of a restart plus
|
|
1386
|
+
* scheduling delay, before the client's stopwatch means anything at all. */
|
|
1387
|
+
const STATE_VERB_SLACK_MS = 30_000;
|
|
1388
|
+
|
|
1389
|
+
type RawSystemctl = (args: string[], timeoutMs: number) => SystemctlResult;
|
|
1390
|
+
|
|
1391
|
+
/** One unit-state reading, in the shape the post-timeout verdict needs. */
|
|
1392
|
+
interface UnitStateSample {
|
|
1393
|
+
activeState?: string;
|
|
1394
|
+
/** `ExecMainStartTimestampMonotonic` — changes on every fresh start of the
|
|
1395
|
+
* unit, which is what makes "the old instance" distinguishable. */
|
|
1396
|
+
startStampMonotonic?: string;
|
|
1397
|
+
/** `TimeoutStopUSec` — integer microseconds, or `infinity`. */
|
|
1398
|
+
timeoutStopUSec?: string;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const STATE_SAMPLE_PROPERTIES = [
|
|
1402
|
+
"--property=ActiveState",
|
|
1403
|
+
"--property=ExecMainStartTimestampMonotonic",
|
|
1404
|
+
"--property=TimeoutStopUSec",
|
|
1405
|
+
] as const;
|
|
1406
|
+
|
|
1407
|
+
function sampleUnitState(unit: string, raw: RawSystemctl): UnitStateSample | undefined {
|
|
1408
|
+
const ran = raw(
|
|
1409
|
+
["show", unit, "--value", ...STATE_SAMPLE_PROPERTIES],
|
|
1410
|
+
SYSTEMCTL_PROBE_TIMEOUT_MS,
|
|
1411
|
+
);
|
|
1412
|
+
if (!ran.ok) return undefined;
|
|
1413
|
+
const values = ran.stdout.split("\n");
|
|
1414
|
+
const pick = (index: number): string | undefined => {
|
|
1415
|
+
const value = values[index]?.trim();
|
|
1416
|
+
return value === undefined || value === "" ? undefined : value;
|
|
1417
|
+
};
|
|
1418
|
+
return {
|
|
1419
|
+
activeState: pick(0),
|
|
1420
|
+
startStampMonotonic: pick(1),
|
|
1421
|
+
timeoutStopUSec: pick(2),
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* The client bound for one state-changing verb: at least the unit's own
|
|
1427
|
+
* `TimeoutStopUSec` — read live, because any fixed constant re-creates this
|
|
1428
|
+
* bug on whatever host drains slower than it — and never below systemd's own
|
|
1429
|
+
* default stop budget. An unreadable budget gets the documented fallback.
|
|
1430
|
+
*/
|
|
1431
|
+
function stateVerbTimeoutMs(sample: UnitStateSample | undefined): number {
|
|
1432
|
+
let stopMs = SYSTEMD_DEFAULT_TIMEOUT_STOP_MS;
|
|
1433
|
+
const usec =
|
|
1434
|
+
sample?.timeoutStopUSec === undefined ? Number.NaN : Number.parseInt(sample.timeoutStopUSec, 10);
|
|
1435
|
+
if (Number.isFinite(usec) && usec > 0) {
|
|
1436
|
+
stopMs = Math.max(Math.ceil(usec / 1000), stopMs);
|
|
1437
|
+
}
|
|
1438
|
+
return stopMs + STATE_VERB_SLACK_MS;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* The stopwatch lost; the unit's own state decides. A state-changing verb that
|
|
1443
|
+
* outlived the client bound succeeded exactly when the manager shows the
|
|
1444
|
+
* verb's goal reached: not-active for `stop`, active with a FRESH start
|
|
1445
|
+
* timestamp for the start/restart family — an unchanged timestamp means the
|
|
1446
|
+
* old instance is still the running one. Anything else stays a failure that
|
|
1447
|
+
* carries both facts.
|
|
1448
|
+
*/
|
|
1449
|
+
function stateVerbTimeoutVerdict(
|
|
1450
|
+
verb: string,
|
|
1451
|
+
unit: string,
|
|
1452
|
+
before: UnitStateSample | undefined,
|
|
1453
|
+
after: UnitStateSample | undefined,
|
|
1454
|
+
timedOut: SystemctlResult,
|
|
1455
|
+
timeoutMs: number,
|
|
1456
|
+
): SystemctlResult {
|
|
1457
|
+
const base =
|
|
1458
|
+
`systemctl ${verb} ${unit} exceeded its ${Math.round(timeoutMs / 1000)}s client bound ` +
|
|
1459
|
+
`(${timedOut.stderr.trim() || "client timeout"})`;
|
|
1460
|
+
if (after?.activeState === undefined) {
|
|
1461
|
+
return {
|
|
1462
|
+
...timedOut,
|
|
1463
|
+
stderr:
|
|
1464
|
+
`${base}, and the unit's state could not be re-read afterwards — ` +
|
|
1465
|
+
`the outcome is unproven, not failed`,
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
if (verb === "stop") {
|
|
1469
|
+
if (after.activeState !== "active" && after.activeState !== "deactivating") {
|
|
1470
|
+
// Goal state reached: the stop completed, whatever the stopwatch said.
|
|
1471
|
+
return { ok: true, stdout: "", stderr: "" };
|
|
1472
|
+
}
|
|
1473
|
+
return {
|
|
1474
|
+
...timedOut,
|
|
1475
|
+
stderr: `${base}; the unit is still ${after.activeState} — the stop did not complete`,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
const freshStart =
|
|
1479
|
+
before !== undefined &&
|
|
1480
|
+
after.startStampMonotonic !== undefined &&
|
|
1481
|
+
after.startStampMonotonic !== before.startStampMonotonic;
|
|
1482
|
+
if (after.activeState === "active" && freshStart) {
|
|
1483
|
+
// The unit restarted; the client simply gave up first.
|
|
1484
|
+
return { ok: true, stdout: "", stderr: "" };
|
|
1485
|
+
}
|
|
1486
|
+
const why =
|
|
1487
|
+
after.activeState !== "active"
|
|
1488
|
+
? `the unit is ${after.activeState}`
|
|
1489
|
+
: before === undefined
|
|
1490
|
+
? "the unit is active but no pre-call sample exists to prove the start is fresh"
|
|
1491
|
+
: "its start timestamp is unchanged, so the running instance predates the call";
|
|
1492
|
+
return { ...timedOut, stderr: `${base}; ${why} — the ${verb} did not complete` };
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Run one systemctl invocation with #1112 semantics: probes keep the short
|
|
1497
|
+
* hung-dbus bound, state-changing verbs wait out the unit's own stop budget,
|
|
1498
|
+
* and a state-changing verb that outlives even that is judged by the unit's
|
|
1499
|
+
* observed state rather than the client's stopwatch. Exported for tests,
|
|
1500
|
+
* which inject their own {@link RawSystemctl}.
|
|
1501
|
+
*/
|
|
1502
|
+
export function runSystemctlInvocation(args: string[], raw: RawSystemctl): SystemctlResult {
|
|
1503
|
+
const verb = args[0] ?? "";
|
|
1504
|
+
if (STATE_CHANGING_VERBS[verb] !== true) return raw(args, SYSTEMCTL_PROBE_TIMEOUT_MS);
|
|
1505
|
+
const unit = args.length > 1 && !args[1]!.startsWith("-") ? args[1]! : SYSTEMD_UNIT;
|
|
1506
|
+
const before = sampleUnitState(unit, raw);
|
|
1507
|
+
const timeoutMs = stateVerbTimeoutMs(before);
|
|
1508
|
+
const ran = raw(args, timeoutMs);
|
|
1509
|
+
if (ran.ok || !ran.timedOut) return ran;
|
|
1510
|
+
return stateVerbTimeoutVerdict(verb, unit, before, sampleUnitState(unit, raw), ran, timeoutMs);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function spawnSystemctl(args: string[], timeoutMs: number): SystemctlResult {
|
|
1256
1514
|
try {
|
|
1257
1515
|
const res = spawnSync("systemctl", args, {
|
|
1258
1516
|
encoding: "utf8",
|
|
1259
|
-
|
|
1260
|
-
// Callers must NOT treat that timeout as "no unit".
|
|
1261
|
-
timeout: 15_000,
|
|
1517
|
+
timeout: timeoutMs,
|
|
1262
1518
|
env: process.env,
|
|
1263
1519
|
});
|
|
1264
1520
|
if (res.error) {
|
|
@@ -1268,6 +1524,7 @@ function defaultSystemctl(args: string[]): SystemctlResult {
|
|
|
1268
1524
|
stdout: "",
|
|
1269
1525
|
stderr: err.message,
|
|
1270
1526
|
...(err.code === "ENOENT" ? { missing: true } : {}),
|
|
1527
|
+
...(err.code === "ETIMEDOUT" ? { timedOut: true } : {}),
|
|
1271
1528
|
};
|
|
1272
1529
|
}
|
|
1273
1530
|
return {
|
|
@@ -1280,6 +1537,11 @@ function defaultSystemctl(args: string[]): SystemctlResult {
|
|
|
1280
1537
|
}
|
|
1281
1538
|
}
|
|
1282
1539
|
|
|
1540
|
+
function defaultSystemctl(args: string[]): SystemctlResult {
|
|
1541
|
+
if (process.env["NODE_ENV"] === "test") return testSystemctl(args);
|
|
1542
|
+
return runSystemctlInvocation(args, spawnSystemctl);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1283
1545
|
let systemctl: SystemctlFn = defaultSystemctl;
|
|
1284
1546
|
|
|
1285
1547
|
/** Shared manager runner. Production reaches the hardcoded binary; test
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -3642,14 +3642,20 @@ export async function sendArmNotice(text: string, surface: ArmNoticeSurface): Pr
|
|
|
3642
3642
|
throw new Error("no Telegram bot token readable — the arm notice cannot send");
|
|
3643
3643
|
}
|
|
3644
3644
|
let topicId: number | undefined;
|
|
3645
|
+
let project: { name: string; workspaceRoot?: string } | undefined;
|
|
3645
3646
|
if (surface.project !== undefined) {
|
|
3646
3647
|
try {
|
|
3647
|
-
|
|
3648
|
+
const p = findProject(loadConfig(), surface.project);
|
|
3649
|
+
topicId = resolveProjectTopicId(p);
|
|
3650
|
+
project = { name: p.name, workspaceRoot: p.workspaceRoot };
|
|
3648
3651
|
} catch {
|
|
3649
3652
|
/* no project config — flat chat, exactly like armTicks */
|
|
3650
3653
|
}
|
|
3651
3654
|
}
|
|
3652
|
-
await sendTelegram(token, channel.owner, text, {
|
|
3655
|
+
await sendTelegram(token, channel.owner, text, {
|
|
3656
|
+
topicId,
|
|
3657
|
+
...(project === undefined ? {} : { project }),
|
|
3658
|
+
});
|
|
3653
3659
|
}
|
|
3654
3660
|
|
|
3655
3661
|
/**
|