@forwardimpact/outpost 3.1.3 → 3.2.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/bin/fit-outpost.js +14 -2
- package/package.json +9 -3
- package/src/agent-runner.js +66 -19
- package/src/kb-manager.js +88 -85
- package/src/outpost.js +386 -342
- package/src/scheduler.js +47 -8
- package/src/socket-server.js +77 -47
- package/src/state-manager.js +32 -35
package/src/scheduler.js
CHANGED
|
@@ -1,10 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scheduler — cron matching, shouldWake logic, wake orchestration.
|
|
3
|
+
*
|
|
4
|
+
* The genuine wall-clock read ("what time is it now?") routes through the
|
|
5
|
+
* injected `runtime.clock` (see `Scheduler#wakeDueAgents` and `failAgent`).
|
|
6
|
+
* The pure cron helpers below receive that `now` as an explicit `Date` and
|
|
7
|
+
* construct further `Date` objects only for deterministic date *arithmetic*
|
|
8
|
+
* over caller-supplied or parsed-from-state inputs (never an ambient no-arg
|
|
9
|
+
* `new Date()`); the AST checker cannot distinguish the two, so this file is
|
|
10
|
+
* allow-listed in check-ambient-deps.allow.yml with that reason.
|
|
3
11
|
*/
|
|
4
12
|
|
|
13
|
+
import { isoTimestamp } from "@forwardimpact/libutil";
|
|
14
|
+
|
|
5
15
|
/** Maximum time an agent can be "active" before being considered stale (35 min). */
|
|
6
16
|
const MAX_AGENT_RUNTIME_MS = 35 * 60_000;
|
|
7
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Build a `Date` for "now" from the injected clock's milliseconds. The cron
|
|
20
|
+
* helpers operate on `Date` objects; this is the single seam that turns the
|
|
21
|
+
* wall-clock read (`runtime.clock.now()`) into one, so callers in other
|
|
22
|
+
* modules never construct an ambient `new Date()` themselves.
|
|
23
|
+
* @param {{now: () => number}} clock
|
|
24
|
+
* @returns {Date}
|
|
25
|
+
*/
|
|
26
|
+
export function nowFromClock(clock) {
|
|
27
|
+
return new Date(clock.now());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format a stored ISO timestamp as a human-readable local time string. Parses
|
|
32
|
+
* an explicit value (never the wall clock); lives here so the only `new Date`
|
|
33
|
+
* construction sites stay in this allow-listed module.
|
|
34
|
+
* @param {string} iso
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function formatLocalTime(iso) {
|
|
38
|
+
return new Date(Date.parse(iso)).toLocaleString();
|
|
39
|
+
}
|
|
40
|
+
|
|
8
41
|
// --- Cron matching (pure functions) ------------------------------------------
|
|
9
42
|
|
|
10
43
|
/**
|
|
@@ -133,12 +166,13 @@ export function computeNextWakeAt(agent, agentState, now) {
|
|
|
133
166
|
/**
|
|
134
167
|
* @param {Object} agentState
|
|
135
168
|
* @param {string} error
|
|
169
|
+
* @param {number} nowMs - Wall-clock milliseconds (from `runtime.clock.now()`)
|
|
136
170
|
*/
|
|
137
|
-
export function failAgent(agentState, error) {
|
|
171
|
+
export function failAgent(agentState, error, nowMs) {
|
|
138
172
|
Object.assign(agentState, {
|
|
139
173
|
status: "failed",
|
|
140
174
|
startedAt: null,
|
|
141
|
-
lastWokeAt:
|
|
175
|
+
lastWokeAt: isoTimestamp(nowMs),
|
|
142
176
|
lastError: String(error).slice(0, 500),
|
|
143
177
|
});
|
|
144
178
|
}
|
|
@@ -151,33 +185,38 @@ export class Scheduler {
|
|
|
151
185
|
#stateManager;
|
|
152
186
|
#agentRunner;
|
|
153
187
|
#log;
|
|
188
|
+
#clock;
|
|
154
189
|
|
|
155
190
|
/**
|
|
156
|
-
* @param {
|
|
191
|
+
* @param {() => Promise<Object>} loadConfig - Returns scheduler config
|
|
157
192
|
* @param {import('./state-manager.js').StateManager} stateManager
|
|
158
193
|
* @param {import('./agent-runner.js').AgentRunner} agentRunner
|
|
159
194
|
* @param {Function} logFn
|
|
195
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
196
|
+
* Injected runtime bag (uses `clock` for the wall-clock read).
|
|
160
197
|
*/
|
|
161
|
-
constructor(loadConfig, stateManager, agentRunner, logFn) {
|
|
198
|
+
constructor(loadConfig, stateManager, agentRunner, logFn, runtime) {
|
|
162
199
|
if (!loadConfig) throw new Error("loadConfig is required");
|
|
163
200
|
if (!stateManager) throw new Error("stateManager is required");
|
|
164
201
|
if (!agentRunner) throw new Error("agentRunner is required");
|
|
165
202
|
if (!logFn) throw new Error("logFn is required");
|
|
203
|
+
if (!runtime?.clock) throw new Error("runtime.clock is required");
|
|
166
204
|
this.#loadConfig = loadConfig;
|
|
167
205
|
this.#stateManager = stateManager;
|
|
168
206
|
this.#agentRunner = agentRunner;
|
|
169
207
|
this.#log = logFn;
|
|
208
|
+
this.#clock = runtime.clock;
|
|
170
209
|
}
|
|
171
210
|
|
|
172
211
|
/**
|
|
173
212
|
* Reset any agents exceeding max runtime, reload config, then wake each agent whose schedule is due.
|
|
174
213
|
*/
|
|
175
214
|
async wakeDueAgents() {
|
|
176
|
-
const config = this.#loadConfig();
|
|
177
|
-
const state = this.#stateManager.load();
|
|
178
|
-
const now =
|
|
215
|
+
const config = await this.#loadConfig();
|
|
216
|
+
const state = await this.#stateManager.load();
|
|
217
|
+
const now = nowFromClock(this.#clock);
|
|
179
218
|
|
|
180
|
-
this.#stateManager.resetStaleAgents(
|
|
219
|
+
await this.#stateManager.resetStaleAgents(
|
|
181
220
|
state,
|
|
182
221
|
{ reason: "Exceeded maximum runtime", maxAge: MAX_AGENT_RUNTIME_MS },
|
|
183
222
|
this.#log,
|
package/src/socket-server.js
CHANGED
|
@@ -5,18 +5,9 @@
|
|
|
5
5
|
import { createServer } from "node:net";
|
|
6
6
|
import { createConnection } from "node:net";
|
|
7
7
|
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
8
|
-
import {
|
|
9
|
-
existsSync,
|
|
10
|
-
unlinkSync,
|
|
11
|
-
chmodSync,
|
|
12
|
-
readdirSync,
|
|
13
|
-
statSync,
|
|
14
|
-
} from "node:fs";
|
|
15
|
-
|
|
16
|
-
const logger = createLogger("outpost");
|
|
17
8
|
import { join, resolve } from "node:path";
|
|
18
9
|
import { homedir } from "node:os";
|
|
19
|
-
import { computeNextWakeAt } from "./scheduler.js";
|
|
10
|
+
import { computeNextWakeAt, nowFromClock } from "./scheduler.js";
|
|
20
11
|
|
|
21
12
|
/** Unix-socket IPC server that handles status queries, wake requests, and shutdown commands. */
|
|
22
13
|
export class SocketServer {
|
|
@@ -28,6 +19,11 @@ export class SocketServer {
|
|
|
28
19
|
#cacheDir;
|
|
29
20
|
#daemonStartedAt;
|
|
30
21
|
#server;
|
|
22
|
+
#fsSync;
|
|
23
|
+
#clock;
|
|
24
|
+
#proc;
|
|
25
|
+
#resolveShutdown;
|
|
26
|
+
#shutdownPromise;
|
|
31
27
|
|
|
32
28
|
/**
|
|
33
29
|
* @param {string} socketPath
|
|
@@ -38,6 +34,8 @@ export class SocketServer {
|
|
|
38
34
|
* @param {Function} logFn
|
|
39
35
|
* @param {string} cacheDir
|
|
40
36
|
* @param {number} daemonStartedAt
|
|
37
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
38
|
+
* Injected runtime bag (uses `fsSync`, `clock`, `proc`).
|
|
41
39
|
*/
|
|
42
40
|
constructor(
|
|
43
41
|
socketPath,
|
|
@@ -48,6 +46,7 @@ export class SocketServer {
|
|
|
48
46
|
logFn,
|
|
49
47
|
cacheDir,
|
|
50
48
|
daemonStartedAt,
|
|
49
|
+
runtime,
|
|
51
50
|
) {
|
|
52
51
|
if (!socketPath) throw new Error("socketPath is required");
|
|
53
52
|
if (!scheduler) throw new Error("scheduler is required");
|
|
@@ -56,6 +55,9 @@ export class SocketServer {
|
|
|
56
55
|
if (!loadConfig) throw new Error("loadConfig is required");
|
|
57
56
|
if (!logFn) throw new Error("logFn is required");
|
|
58
57
|
if (!cacheDir) throw new Error("cacheDir is required");
|
|
58
|
+
if (!runtime?.fsSync) throw new Error("runtime.fsSync is required");
|
|
59
|
+
if (!runtime?.clock) throw new Error("runtime.clock is required");
|
|
60
|
+
if (!runtime?.proc) throw new Error("runtime.proc is required");
|
|
59
61
|
this.#socketPath = socketPath;
|
|
60
62
|
this.#agentRunner = agentRunner;
|
|
61
63
|
this.#stateManager = stateManager;
|
|
@@ -63,6 +65,21 @@ export class SocketServer {
|
|
|
63
65
|
this.#log = logFn;
|
|
64
66
|
this.#cacheDir = cacheDir;
|
|
65
67
|
this.#daemonStartedAt = daemonStartedAt;
|
|
68
|
+
this.#fsSync = runtime.fsSync;
|
|
69
|
+
this.#clock = runtime.clock;
|
|
70
|
+
this.#proc = runtime.proc;
|
|
71
|
+
this.#shutdownPromise = new Promise((r) => {
|
|
72
|
+
this.#resolveShutdown = r;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolves once a shutdown has been requested (via socket or signal). The
|
|
78
|
+
* daemon awaits this, then the bin translates it to `runtime.proc.exit(0)`.
|
|
79
|
+
* @returns {Promise<void>}
|
|
80
|
+
*/
|
|
81
|
+
whenStopped() {
|
|
82
|
+
return this.#shutdownPromise;
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
/**
|
|
@@ -90,13 +107,13 @@ export class SocketServer {
|
|
|
90
107
|
* @returns {string|null}
|
|
91
108
|
*/
|
|
92
109
|
#latestFileByMtime(dir, filter) {
|
|
93
|
-
const matches = readdirSync(dir).filter(filter);
|
|
110
|
+
const matches = this.#fsSync.readdirSync(dir).filter(filter);
|
|
94
111
|
if (matches.length === 0) return null;
|
|
95
112
|
let latest = join(dir, matches[0]);
|
|
96
|
-
let latestMtime = statSync(latest).mtimeMs;
|
|
113
|
+
let latestMtime = this.#fsSync.statSync(latest).mtimeMs;
|
|
97
114
|
for (let i = 1; i < matches.length; i++) {
|
|
98
115
|
const p = join(dir, matches[i]);
|
|
99
|
-
const mt = statSync(p).mtimeMs;
|
|
116
|
+
const mt = this.#fsSync.statSync(p).mtimeMs;
|
|
100
117
|
if (mt > latestMtime) {
|
|
101
118
|
latest = p;
|
|
102
119
|
latestMtime = mt;
|
|
@@ -113,7 +130,7 @@ export class SocketServer {
|
|
|
113
130
|
*/
|
|
114
131
|
#resolveBriefingFile(agentName, agentConfig) {
|
|
115
132
|
const stateDir = join(this.#cacheDir, "state");
|
|
116
|
-
if (existsSync(stateDir)) {
|
|
133
|
+
if (this.#fsSync.existsSync(stateDir)) {
|
|
117
134
|
const prefix = agentName.replace(/-/g, "_") + "_";
|
|
118
135
|
const found = this.#latestFileByMtime(
|
|
119
136
|
stateDir,
|
|
@@ -128,8 +145,9 @@ export class SocketServer {
|
|
|
128
145
|
"knowledge",
|
|
129
146
|
"Briefings",
|
|
130
147
|
);
|
|
131
|
-
if (existsSync(dir)) {
|
|
132
|
-
const files =
|
|
148
|
+
if (this.#fsSync.existsSync(dir)) {
|
|
149
|
+
const files = this.#fsSync
|
|
150
|
+
.readdirSync(dir)
|
|
133
151
|
.filter((f) => f.endsWith(".md"))
|
|
134
152
|
.sort()
|
|
135
153
|
.reverse();
|
|
@@ -143,10 +161,10 @@ export class SocketServer {
|
|
|
143
161
|
/**
|
|
144
162
|
* @param {import('node:net').Socket} socket
|
|
145
163
|
*/
|
|
146
|
-
#handleStatusRequest(socket) {
|
|
147
|
-
const config = this.#loadConfig();
|
|
148
|
-
const state = this.#stateManager.load();
|
|
149
|
-
const now =
|
|
164
|
+
async #handleStatusRequest(socket) {
|
|
165
|
+
const config = await this.#loadConfig();
|
|
166
|
+
const state = await this.#stateManager.load();
|
|
167
|
+
const now = nowFromClock(this.#clock);
|
|
150
168
|
const agents = {};
|
|
151
169
|
|
|
152
170
|
for (const [name, agent] of Object.entries(config.agents)) {
|
|
@@ -169,7 +187,7 @@ export class SocketServer {
|
|
|
169
187
|
this.#send(socket, {
|
|
170
188
|
type: "status",
|
|
171
189
|
uptime: this.#daemonStartedAt
|
|
172
|
-
? Math.floor((
|
|
190
|
+
? Math.floor((this.#clock.now() - this.#daemonStartedAt) / 1000)
|
|
173
191
|
: 0,
|
|
174
192
|
agents,
|
|
175
193
|
});
|
|
@@ -179,7 +197,7 @@ export class SocketServer {
|
|
|
179
197
|
* @param {import('node:net').Socket} socket
|
|
180
198
|
* @param {string} line
|
|
181
199
|
*/
|
|
182
|
-
#handleMessage(socket, line) {
|
|
200
|
+
async #handleMessage(socket, line) {
|
|
183
201
|
let request;
|
|
184
202
|
try {
|
|
185
203
|
request = JSON.parse(line);
|
|
@@ -194,8 +212,8 @@ export class SocketServer {
|
|
|
194
212
|
this.#log("Shutdown requested via socket.");
|
|
195
213
|
this.#send(socket, { type: "ack", command: "shutdown" });
|
|
196
214
|
socket.end();
|
|
197
|
-
this.#
|
|
198
|
-
|
|
215
|
+
this.#requestShutdown();
|
|
216
|
+
return;
|
|
199
217
|
}
|
|
200
218
|
|
|
201
219
|
if (request.type === "wake") {
|
|
@@ -203,7 +221,7 @@ export class SocketServer {
|
|
|
203
221
|
this.#send(socket, { type: "error", message: "Missing agent name" });
|
|
204
222
|
return;
|
|
205
223
|
}
|
|
206
|
-
const config = this.#loadConfig();
|
|
224
|
+
const config = await this.#loadConfig();
|
|
207
225
|
const agent = config.agents[request.agent];
|
|
208
226
|
if (!agent) {
|
|
209
227
|
this.#send(socket, {
|
|
@@ -217,7 +235,7 @@ export class SocketServer {
|
|
|
217
235
|
command: "wake",
|
|
218
236
|
agent: request.agent,
|
|
219
237
|
});
|
|
220
|
-
const state = this.#stateManager.load();
|
|
238
|
+
const state = await this.#stateManager.load();
|
|
221
239
|
this.#agentRunner
|
|
222
240
|
.wake(request.agent, agent, state, config.env)
|
|
223
241
|
.catch(() => {});
|
|
@@ -231,12 +249,27 @@ export class SocketServer {
|
|
|
231
249
|
}
|
|
232
250
|
|
|
233
251
|
/**
|
|
234
|
-
*
|
|
252
|
+
* Tear down active children and the listening socket, then signal the daemon
|
|
253
|
+
* (via `whenStopped`) that it is safe to exit. The bin owns the actual
|
|
254
|
+
* `runtime.proc.exit` call (design Decision 4).
|
|
255
|
+
*/
|
|
256
|
+
#requestShutdown() {
|
|
257
|
+
this.#agentRunner.killActiveChildren();
|
|
258
|
+
if (this.#server) this.#server.close();
|
|
259
|
+
try {
|
|
260
|
+
this.#fsSync.unlinkSync(this.#socketPath);
|
|
261
|
+
} catch {}
|
|
262
|
+
this.#resolveShutdown();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Remove any existing socket file, bind the server, and register
|
|
267
|
+
* SIGTERM/SIGINT handlers that request a graceful shutdown.
|
|
235
268
|
* @returns {import('node:net').Server}
|
|
236
269
|
*/
|
|
237
270
|
start() {
|
|
238
271
|
try {
|
|
239
|
-
unlinkSync(this.#socketPath);
|
|
272
|
+
this.#fsSync.unlinkSync(this.#socketPath);
|
|
240
273
|
} catch {}
|
|
241
274
|
|
|
242
275
|
this.#server = createServer((socket) => {
|
|
@@ -247,14 +280,14 @@ export class SocketServer {
|
|
|
247
280
|
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
248
281
|
const line = buffer.slice(0, idx).trim();
|
|
249
282
|
buffer = buffer.slice(idx + 1);
|
|
250
|
-
if (line) this.#handleMessage(socket, line);
|
|
283
|
+
if (line) void this.#handleMessage(socket, line);
|
|
251
284
|
}
|
|
252
285
|
});
|
|
253
286
|
socket.on("error", () => {});
|
|
254
287
|
});
|
|
255
288
|
|
|
256
289
|
this.#server.listen(this.#socketPath, () => {
|
|
257
|
-
chmodSync(this.#socketPath, 0o600);
|
|
290
|
+
this.#fsSync.chmodSync(this.#socketPath, 0o600);
|
|
258
291
|
this.#log(`Socket server listening on ${this.#socketPath}`);
|
|
259
292
|
});
|
|
260
293
|
|
|
@@ -262,16 +295,8 @@ export class SocketServer {
|
|
|
262
295
|
this.#log(`Socket server error: ${err.message}`);
|
|
263
296
|
});
|
|
264
297
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.#server.close();
|
|
268
|
-
try {
|
|
269
|
-
unlinkSync(this.#socketPath);
|
|
270
|
-
} catch {}
|
|
271
|
-
process.exit(0);
|
|
272
|
-
};
|
|
273
|
-
process.on("SIGTERM", cleanup);
|
|
274
|
-
process.on("SIGINT", cleanup);
|
|
298
|
+
this.#proc.on("SIGTERM", () => this.#requestShutdown());
|
|
299
|
+
this.#proc.on("SIGINT", () => this.#requestShutdown());
|
|
275
300
|
|
|
276
301
|
return this.#server;
|
|
277
302
|
}
|
|
@@ -284,7 +309,7 @@ export class SocketServer {
|
|
|
284
309
|
this.#server.close();
|
|
285
310
|
}
|
|
286
311
|
try {
|
|
287
|
-
unlinkSync(this.#socketPath);
|
|
312
|
+
this.#fsSync.unlinkSync(this.#socketPath);
|
|
288
313
|
} catch {}
|
|
289
314
|
}
|
|
290
315
|
}
|
|
@@ -292,16 +317,21 @@ export class SocketServer {
|
|
|
292
317
|
/**
|
|
293
318
|
* Connect to the daemon socket and request graceful shutdown.
|
|
294
319
|
* @param {string} socketPath
|
|
320
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
321
|
+
* Injected runtime bag (uses `fsSync` and `clock`).
|
|
295
322
|
* @returns {Promise<boolean>}
|
|
296
323
|
*/
|
|
297
|
-
export async function requestShutdown(socketPath) {
|
|
298
|
-
if (!
|
|
324
|
+
export async function requestShutdown(socketPath, runtime) {
|
|
325
|
+
if (!runtime?.fsSync) throw new Error("runtime.fsSync is required");
|
|
326
|
+
if (!runtime?.clock) throw new Error("runtime.clock is required");
|
|
327
|
+
const logger = createLogger("outpost", runtime);
|
|
328
|
+
if (!runtime.fsSync.existsSync(socketPath)) {
|
|
299
329
|
logger.info("Daemon not running (no socket).");
|
|
300
330
|
return false;
|
|
301
331
|
}
|
|
302
332
|
|
|
303
333
|
return new Promise((resolve) => {
|
|
304
|
-
const timeout = setTimeout(() => {
|
|
334
|
+
const timeout = runtime.clock.setTimeout(() => {
|
|
305
335
|
logger.info("Shutdown timed out.");
|
|
306
336
|
socket.destroy();
|
|
307
337
|
resolve(false);
|
|
@@ -315,7 +345,7 @@ export async function requestShutdown(socketPath) {
|
|
|
315
345
|
socket.on("data", (data) => {
|
|
316
346
|
buffer += data.toString();
|
|
317
347
|
if (buffer.includes("\n")) {
|
|
318
|
-
clearTimeout(timeout);
|
|
348
|
+
runtime.clock.clearTimeout(timeout);
|
|
319
349
|
logger.info("Daemon stopped.");
|
|
320
350
|
socket.destroy();
|
|
321
351
|
resolve(true);
|
|
@@ -323,13 +353,13 @@ export async function requestShutdown(socketPath) {
|
|
|
323
353
|
});
|
|
324
354
|
|
|
325
355
|
socket.on("error", () => {
|
|
326
|
-
clearTimeout(timeout);
|
|
356
|
+
runtime.clock.clearTimeout(timeout);
|
|
327
357
|
logger.info("Daemon not running (connection refused).");
|
|
328
358
|
resolve(false);
|
|
329
359
|
});
|
|
330
360
|
|
|
331
361
|
socket.on("close", () => {
|
|
332
|
-
clearTimeout(timeout);
|
|
362
|
+
runtime.clock.clearTimeout(timeout);
|
|
333
363
|
resolve(true);
|
|
334
364
|
});
|
|
335
365
|
});
|
package/src/state-manager.js
CHANGED
|
@@ -2,41 +2,46 @@
|
|
|
2
2
|
* StateManager — load/save state.json, reset stale agents.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
6
5
|
import { dirname, join } from "node:path";
|
|
6
|
+
import { isoTimestamp } from "@forwardimpact/libutil";
|
|
7
7
|
|
|
8
8
|
/** Persist and query agent scheduler state from a JSON file on disk. */
|
|
9
9
|
export class StateManager {
|
|
10
10
|
#statePath;
|
|
11
11
|
#fs;
|
|
12
|
+
#clock;
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* @param {string} statePath - Path to state.json
|
|
15
|
-
* @param {
|
|
16
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
17
|
+
* Injected runtime bag (uses `fs` (async) and `clock`).
|
|
16
18
|
*/
|
|
17
|
-
constructor(statePath,
|
|
19
|
+
constructor(statePath, runtime) {
|
|
18
20
|
if (!statePath) throw new Error("statePath is required");
|
|
19
|
-
if (!fs) throw new Error("fs is required");
|
|
21
|
+
if (!runtime?.fs) throw new Error("runtime.fs is required");
|
|
22
|
+
if (!runtime?.clock) throw new Error("runtime.clock is required");
|
|
20
23
|
this.#statePath = statePath;
|
|
21
|
-
this.#fs = fs;
|
|
24
|
+
this.#fs = runtime.fs;
|
|
25
|
+
this.#clock = runtime.clock;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
|
-
* Read and parse state from disk; on any read or parse error, write a fresh
|
|
26
|
-
*
|
|
29
|
+
* Read and parse state from disk; on any read or parse error, write a fresh
|
|
30
|
+
* empty state and return it.
|
|
31
|
+
* @returns {Promise<Object>}
|
|
27
32
|
*/
|
|
28
|
-
load() {
|
|
33
|
+
async load() {
|
|
29
34
|
try {
|
|
30
|
-
const raw = JSON.parse(this.#fs.
|
|
35
|
+
const raw = JSON.parse(await this.#fs.readFile(this.#statePath, "utf8"));
|
|
31
36
|
if (!raw || typeof raw !== "object" || !raw.agents) {
|
|
32
37
|
const state = { agents: {} };
|
|
33
|
-
this.save(state);
|
|
38
|
+
await this.save(state);
|
|
34
39
|
return state;
|
|
35
40
|
}
|
|
36
41
|
return raw;
|
|
37
42
|
} catch {
|
|
38
43
|
const state = { agents: {} };
|
|
39
|
-
this.save(state);
|
|
44
|
+
await this.save(state);
|
|
40
45
|
return state;
|
|
41
46
|
}
|
|
42
47
|
}
|
|
@@ -44,10 +49,11 @@ export class StateManager {
|
|
|
44
49
|
/**
|
|
45
50
|
* Save state to disk
|
|
46
51
|
* @param {Object} state
|
|
52
|
+
* @returns {Promise<void>}
|
|
47
53
|
*/
|
|
48
|
-
save(state) {
|
|
49
|
-
this.#fs.
|
|
50
|
-
this.#fs.
|
|
54
|
+
async save(state) {
|
|
55
|
+
await this.#fs.mkdir(dirname(this.#statePath), { recursive: true });
|
|
56
|
+
await this.#fs.writeFile(
|
|
51
57
|
this.#statePath,
|
|
52
58
|
JSON.stringify(state, null, 2) + "\n",
|
|
53
59
|
);
|
|
@@ -58,14 +64,14 @@ export class StateManager {
|
|
|
58
64
|
* @param {Object} state
|
|
59
65
|
* @param {{ reason: string, maxAge?: number }} opts
|
|
60
66
|
* @param {Function} logFn
|
|
61
|
-
* @returns {number} Number of agents reset
|
|
67
|
+
* @returns {Promise<number>} Number of agents reset
|
|
62
68
|
*/
|
|
63
|
-
resetStaleAgents(state, { reason, maxAge }, logFn) {
|
|
69
|
+
async resetStaleAgents(state, { reason, maxAge }, logFn) {
|
|
64
70
|
let resetCount = 0;
|
|
65
71
|
for (const [name, as] of Object.entries(state.agents)) {
|
|
66
72
|
if (as.status !== "active") continue;
|
|
67
73
|
if (maxAge && as.startedAt) {
|
|
68
|
-
const elapsed =
|
|
74
|
+
const elapsed = this.#clock.now() - Date.parse(as.startedAt);
|
|
69
75
|
if (elapsed < maxAge) continue;
|
|
70
76
|
}
|
|
71
77
|
logFn(`Resetting stale agent: ${name} (${reason})`);
|
|
@@ -76,7 +82,7 @@ export class StateManager {
|
|
|
76
82
|
});
|
|
77
83
|
resetCount++;
|
|
78
84
|
}
|
|
79
|
-
if (resetCount > 0) this.save(state);
|
|
85
|
+
if (resetCount > 0) await this.save(state);
|
|
80
86
|
return resetCount;
|
|
81
87
|
}
|
|
82
88
|
|
|
@@ -86,8 +92,9 @@ export class StateManager {
|
|
|
86
92
|
* @param {string} stdout
|
|
87
93
|
* @param {string} agentName
|
|
88
94
|
* @param {string} cacheDir - Cache directory for state files
|
|
95
|
+
* @returns {Promise<void>}
|
|
89
96
|
*/
|
|
90
|
-
updateAgentState(agentState, stdout, agentName, cacheDir) {
|
|
97
|
+
async updateAgentState(agentState, stdout, agentName, cacheDir) {
|
|
91
98
|
const lines = stdout.split("\n");
|
|
92
99
|
const decisionLine = lines.find((l) => l.startsWith("Decision:"));
|
|
93
100
|
const actionLine = lines.find((l) => l.startsWith("Action:"));
|
|
@@ -95,7 +102,7 @@ export class StateManager {
|
|
|
95
102
|
Object.assign(agentState, {
|
|
96
103
|
status: "idle",
|
|
97
104
|
startedAt: null,
|
|
98
|
-
lastWokeAt:
|
|
105
|
+
lastWokeAt: isoTimestamp(this.#clock.now()),
|
|
99
106
|
lastDecision: decisionLine
|
|
100
107
|
? decisionLine.slice(10).trim()
|
|
101
108
|
: stdout.slice(0, 200),
|
|
@@ -106,21 +113,11 @@ export class StateManager {
|
|
|
106
113
|
|
|
107
114
|
// Save output as briefing fallback
|
|
108
115
|
const stateDir = join(cacheDir, "state");
|
|
109
|
-
this.#fs.
|
|
116
|
+
await this.#fs.mkdir(stateDir, { recursive: true });
|
|
110
117
|
const prefix = agentName.replace(/-/g, "_");
|
|
111
|
-
this.#fs.
|
|
118
|
+
await this.#fs.writeFile(
|
|
119
|
+
join(stateDir, `${prefix}_last_output.md`),
|
|
120
|
+
stdout,
|
|
121
|
+
);
|
|
112
122
|
}
|
|
113
123
|
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Create a StateManager with real fs dependencies
|
|
117
|
-
* @param {string} statePath
|
|
118
|
-
* @returns {StateManager}
|
|
119
|
-
*/
|
|
120
|
-
export function createStateManager(statePath) {
|
|
121
|
-
return new StateManager(statePath, {
|
|
122
|
-
readFileSync,
|
|
123
|
-
writeFileSync,
|
|
124
|
-
mkdirSync,
|
|
125
|
-
});
|
|
126
|
-
}
|