@feigi/fleet-ctl 3.83.2 → 3.84.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/package.json +3 -2
- package/scripts/controller-alarm.mjs +138 -0
- package/scripts/fleet-state.mjs +10 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feigi/fleet-ctl",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.84.0",
|
|
4
4
|
"description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
],
|
|
20
20
|
"omp": {
|
|
21
21
|
"extensions": [
|
|
22
|
-
"scripts/member-write-guard.mjs"
|
|
22
|
+
"scripts/member-write-guard.mjs",
|
|
23
|
+
"scripts/controller-alarm.mjs"
|
|
23
24
|
]
|
|
24
25
|
}
|
|
25
26
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The run-team controller's alarm. An omp extension: `plugin/package.json#omp.extensions`
|
|
2
|
+
// names this file beside `member-write-guard.mjs`, and a native install loads it
|
|
3
|
+
// into every omp session on the box — the controller's, every member's, and
|
|
4
|
+
// every unrelated session's — so it is inert unless its gate passes and it
|
|
5
|
+
// never throws.
|
|
6
|
+
//
|
|
7
|
+
// WHAT IT DOES. Nothing outside the omp process can start a turn in an idle
|
|
8
|
+
// session, and a controller that has ended its turn is idle. From inside the
|
|
9
|
+
// process `ctx.setInterval` plus `pi.sendUserMessage` can: every 30 s this
|
|
10
|
+
// checks whether the controller's beat has fallen due and, when it has, sends
|
|
11
|
+
// the one line that tells the controller to run the level-check
|
|
12
|
+
// (`fleet-event.mjs heartbeat`). The line is an ordinary user turn, told apart
|
|
13
|
+
// from a typed message by its `[fleet-alarm]` marker.
|
|
14
|
+
//
|
|
15
|
+
// THE GATE, `alarmDue()` — pure, no I/O, no clock read. All of these hold:
|
|
16
|
+
// - the session is idle;
|
|
17
|
+
// - `ctx.agent.kind` is `main`: a subagent's session never alarms;
|
|
18
|
+
// - `.fleet/heartbeat.json`'s `controller.pid` is this process's pid, so two
|
|
19
|
+
// omp sessions in one checkout alarm only the one the ledger recorded, and
|
|
20
|
+
// a session in an unrelated repository alarms nothing;
|
|
21
|
+
// - a `beat` is recorded and its `stopped` is empty: a stopped beat is a run
|
|
22
|
+
// that ended on purpose, and `""` — what every ordinary beat carries — is
|
|
23
|
+
// as empty as `null`;
|
|
24
|
+
// - `now >= beat.at + beat.interval * 1000`;
|
|
25
|
+
// - this beat has not already been alarmed within the last interval. A
|
|
26
|
+
// controller that never re-marks is alarmed again once per interval, not
|
|
27
|
+
// once per poll.
|
|
28
|
+
// A poll that cannot read or judge the file is "not due", and writes nothing
|
|
29
|
+
// to stderr on any poll: `readState` is handed a sink that drops its warnings,
|
|
30
|
+
// because the default would repeat the same line in the TUI every 30 s.
|
|
31
|
+
//
|
|
32
|
+
// IT NEVER ASKS TO QUEUE. The message goes out only when the session is idle at
|
|
33
|
+
// the check, with no `deliverAs`. A queued follow-up is lost when the
|
|
34
|
+
// maintainer presses Esc, and a steer backgrounds the controller's running bash
|
|
35
|
+
// call. A busy controller is skipped until the next poll, and nothing is held
|
|
36
|
+
// for it. omp's `sendUserMessage` still awaits before it dispatches, and a turn
|
|
37
|
+
// that starts in that gap takes the line as a steer: idle at the check narrows
|
|
38
|
+
// that window and does not close it.
|
|
39
|
+
//
|
|
40
|
+
// IT ONLY READS `beat`. `fleet-heartbeat.mjs` is the one writer of the mark and
|
|
41
|
+
// the one place the back-off is computed; this file never marks, never stops
|
|
42
|
+
// and never writes `.fleet/`. It does not suppress itself while the maintainer
|
|
43
|
+
// is typing: a draft in the editor survives the turn it starts.
|
|
44
|
+
//
|
|
45
|
+
// THE PROOF IT IS ARMED. At `session_start`, for the main session only, and
|
|
46
|
+
// only AFTER the timer is set, it writes `~/.fleet/alarm/<pid>.json` holding
|
|
47
|
+
// `{ pid, lstart, fleet }`: this process's pid, its start time as `proc.mjs`
|
|
48
|
+
// reads it, and the `.fleet/` path it resolved. The file is for run-team's
|
|
49
|
+
// phase 0 to check, and nothing reads it yet: this file only writes it. A
|
|
50
|
+
// session that set no timer writes no file, so the file proves the timer is
|
|
51
|
+
// armed against that repository's `.fleet/` and not only that the extension
|
|
52
|
+
// loaded. A start time that cannot be read writes no file, the safe direction:
|
|
53
|
+
// a missing file can only read as a timer not armed. The `.fleet/` path is
|
|
54
|
+
// resolved once, from `ctx.cwd`; outside a git repository, or where git fails
|
|
55
|
+
// or times out, no timer is set and no file is written. A leftover file from a
|
|
56
|
+
// dead session is harmless — its start time never matches a live process.
|
|
57
|
+
//
|
|
58
|
+
// Zero deps: node builtins and sibling scripts only.
|
|
59
|
+
|
|
60
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
61
|
+
import { homedir } from "node:os";
|
|
62
|
+
import { join } from "node:path";
|
|
63
|
+
import { fleetFile, FleetDirUnresolvable } from "./fleet-dir.mjs";
|
|
64
|
+
import { readState, stateFileIn } from "./fleet-state.mjs";
|
|
65
|
+
import { processSource } from "./proc.mjs";
|
|
66
|
+
|
|
67
|
+
export const POLL_MS = 30_000;
|
|
68
|
+
export const ALARM_LINE = "[fleet-alarm] run ~/.fleet/bin/fleet-run fleet-event.mjs heartbeat";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether the alarm fires now. `beat` is `{ at, interval, stopped }` (`at` in
|
|
72
|
+
* ms, `interval` in seconds) or null; `controller` is `{ pid }` or null;
|
|
73
|
+
* `firedFor` is null or `{ at, firedAt }`, the beat the last fire answered and
|
|
74
|
+
* when.
|
|
75
|
+
*/
|
|
76
|
+
export function alarmDue({ kind, pid, controller, beat, idle, now, firedFor }) {
|
|
77
|
+
if (idle !== true) return false;
|
|
78
|
+
if (kind !== "main") return false;
|
|
79
|
+
if (pid == null || controller?.pid !== pid) return false;
|
|
80
|
+
if (!beat || beat.stopped) return false;
|
|
81
|
+
const intervalMs = beat.interval * 1000;
|
|
82
|
+
if (!(now >= beat.at + intervalMs)) return false;
|
|
83
|
+
if (firedFor && firedFor.at === beat.at && now < firedFor.firedAt + intervalMs) return false;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The extension factory omp calls once per session. */
|
|
88
|
+
export default function controllerAlarm(pi) {
|
|
89
|
+
let latched = false;
|
|
90
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
91
|
+
try {
|
|
92
|
+
if (latched || ctx?.agent?.kind !== "main") return;
|
|
93
|
+
latched = true;
|
|
94
|
+
arm(pi, ctx);
|
|
95
|
+
} catch {
|
|
96
|
+
// Loads into every session on the box: a fault here must cost that
|
|
97
|
+
// session nothing.
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function arm(pi, ctx) {
|
|
103
|
+
let fleetDir;
|
|
104
|
+
try {
|
|
105
|
+
fleetDir = fleetFile(null, { cwd: ctx.cwd });
|
|
106
|
+
} catch (e) {
|
|
107
|
+
if (e instanceof FleetDirUnresolvable) return;
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
const beatFile = stateFileIn(fleetDir);
|
|
111
|
+
let firedFor = null;
|
|
112
|
+
|
|
113
|
+
const poll = () => {
|
|
114
|
+
try {
|
|
115
|
+
const { controller, beat } = readState(beatFile, "fleet-alarm", () => {});
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const due = alarmDue({ kind: ctx.agent.kind, pid: process.pid, controller, beat, idle: ctx.isIdle(), now, firedFor });
|
|
118
|
+
if (!due) return;
|
|
119
|
+
// A send that rejects later must not surface as an unhandled rejection in a session that is not ours.
|
|
120
|
+
Promise.resolve(pi.sendUserMessage(ALARM_LINE)).catch(() => {});
|
|
121
|
+
firedFor = { at: beat.at, firedAt: now };
|
|
122
|
+
} catch {
|
|
123
|
+
// Cannot read or judge: not due.
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
ctx.setInterval(poll, POLL_MS);
|
|
127
|
+
|
|
128
|
+
let lstart = null;
|
|
129
|
+
try {
|
|
130
|
+
lstart = processSource().startTime(process.pid);
|
|
131
|
+
} catch {
|
|
132
|
+
// An unreadable process table is an unreadable start time.
|
|
133
|
+
}
|
|
134
|
+
if (typeof lstart !== "string" || lstart === "") return;
|
|
135
|
+
const dir = join(homedir(), ".fleet", "alarm");
|
|
136
|
+
mkdirSync(dir, { recursive: true });
|
|
137
|
+
writeFileSync(join(dir, `${process.pid}.json`), `${JSON.stringify({ pid: process.pid, lstart, fleet: fleetDir })}\n`);
|
|
138
|
+
}
|
package/scripts/fleet-state.mjs
CHANGED
|
@@ -123,7 +123,12 @@ const PRIORS = ["dead", "ancestor", "none"];
|
|
|
123
123
|
// below accepts: it reads as no record, and the next write would drop the
|
|
124
124
|
// key. All of them come back flagged `degraded`, for the one caller that
|
|
125
125
|
// must not read them as a file with nothing in it.
|
|
126
|
-
|
|
126
|
+
//
|
|
127
|
+
// `warn` is where those announcements go, `console.error` by default so every
|
|
128
|
+
// caller that passes nothing is unchanged. A caller that polls on a timer and
|
|
129
|
+
// must not repeat a degraded read on a screen every few seconds passes its own
|
|
130
|
+
// sink instead of copying the validation below.
|
|
131
|
+
export function readState(path, name, warn = console.error) {
|
|
127
132
|
// `beat: null` is the absent MARK, and it is not the same value as a mark
|
|
128
133
|
// whose fields are zero: nothing has been seen beating, so there is nothing
|
|
129
134
|
// to judge and nothing to report. A zeroed mark would date the beat to the
|
|
@@ -149,7 +154,7 @@ export function readState(path, name) {
|
|
|
149
154
|
// degraded read reported as a fresh run, which is the failure class this
|
|
150
155
|
// whole ticket exists to close.
|
|
151
156
|
if (e.code !== "ENOENT") {
|
|
152
|
-
|
|
157
|
+
warn(`${name}: WARNING could not read ${path} (${e.message}) — restarting at the base interval`);
|
|
153
158
|
return makeDegraded("exists but cannot be read");
|
|
154
159
|
}
|
|
155
160
|
return fresh;
|
|
@@ -158,11 +163,11 @@ export function readState(path, name) {
|
|
|
158
163
|
try {
|
|
159
164
|
parsed = JSON.parse(raw);
|
|
160
165
|
} catch (e) {
|
|
161
|
-
|
|
166
|
+
warn(`${name}: WARNING ${path} is not JSON (${e.message}) — restarting at the base interval`);
|
|
162
167
|
return makeDegraded("is not JSON");
|
|
163
168
|
}
|
|
164
169
|
if (!isRecord(parsed)) {
|
|
165
|
-
|
|
170
|
+
warn(`${name}: WARNING ${path} is not an object — restarting at the base interval`);
|
|
166
171
|
return makeDegraded("is not a JSON object");
|
|
167
172
|
}
|
|
168
173
|
// Per-field validation, not all-or-nothing: a file carrying a good `quiet`
|
|
@@ -254,7 +259,7 @@ export function readState(path, name) {
|
|
|
254
259
|
// causes, and flagged.
|
|
255
260
|
const record = ctl(controller);
|
|
256
261
|
if ("controller" in parsed && record === null) {
|
|
257
|
-
|
|
262
|
+
warn(`${name}: WARNING ${path} holds a controller record that cannot be judged — restarting at the base interval`);
|
|
258
263
|
return makeDegraded("holds a controller record that cannot be judged");
|
|
259
264
|
}
|
|
260
265
|
return {
|