@forwardimpact/outpost 3.1.2 → 3.1.4
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 +23 -2
- package/package.json +4 -3
- package/src/agent-runner.js +59 -19
- package/src/index.js +2 -2
- package/src/kb-manager.js +76 -75
- package/src/outpost.js +385 -340
- package/src/scheduler.js +47 -8
- package/src/socket-server.js +76 -45
- 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,11 @@
|
|
|
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
8
|
|
|
16
9
|
const logger = createLogger("outpost");
|
|
17
10
|
import { join, resolve } from "node:path";
|
|
18
11
|
import { homedir } from "node:os";
|
|
19
|
-
import { computeNextWakeAt } from "./scheduler.js";
|
|
12
|
+
import { computeNextWakeAt, nowFromClock } from "./scheduler.js";
|
|
20
13
|
|
|
21
14
|
/** Unix-socket IPC server that handles status queries, wake requests, and shutdown commands. */
|
|
22
15
|
export class SocketServer {
|
|
@@ -28,6 +21,11 @@ export class SocketServer {
|
|
|
28
21
|
#cacheDir;
|
|
29
22
|
#daemonStartedAt;
|
|
30
23
|
#server;
|
|
24
|
+
#fsSync;
|
|
25
|
+
#clock;
|
|
26
|
+
#proc;
|
|
27
|
+
#resolveShutdown;
|
|
28
|
+
#shutdownPromise;
|
|
31
29
|
|
|
32
30
|
/**
|
|
33
31
|
* @param {string} socketPath
|
|
@@ -38,6 +36,8 @@ export class SocketServer {
|
|
|
38
36
|
* @param {Function} logFn
|
|
39
37
|
* @param {string} cacheDir
|
|
40
38
|
* @param {number} daemonStartedAt
|
|
39
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
40
|
+
* Injected runtime bag (uses `fsSync`, `clock`, `proc`).
|
|
41
41
|
*/
|
|
42
42
|
constructor(
|
|
43
43
|
socketPath,
|
|
@@ -48,6 +48,7 @@ export class SocketServer {
|
|
|
48
48
|
logFn,
|
|
49
49
|
cacheDir,
|
|
50
50
|
daemonStartedAt,
|
|
51
|
+
runtime,
|
|
51
52
|
) {
|
|
52
53
|
if (!socketPath) throw new Error("socketPath is required");
|
|
53
54
|
if (!scheduler) throw new Error("scheduler is required");
|
|
@@ -56,6 +57,9 @@ export class SocketServer {
|
|
|
56
57
|
if (!loadConfig) throw new Error("loadConfig is required");
|
|
57
58
|
if (!logFn) throw new Error("logFn is required");
|
|
58
59
|
if (!cacheDir) throw new Error("cacheDir is required");
|
|
60
|
+
if (!runtime?.fsSync) throw new Error("runtime.fsSync is required");
|
|
61
|
+
if (!runtime?.clock) throw new Error("runtime.clock is required");
|
|
62
|
+
if (!runtime?.proc) throw new Error("runtime.proc is required");
|
|
59
63
|
this.#socketPath = socketPath;
|
|
60
64
|
this.#agentRunner = agentRunner;
|
|
61
65
|
this.#stateManager = stateManager;
|
|
@@ -63,6 +67,21 @@ export class SocketServer {
|
|
|
63
67
|
this.#log = logFn;
|
|
64
68
|
this.#cacheDir = cacheDir;
|
|
65
69
|
this.#daemonStartedAt = daemonStartedAt;
|
|
70
|
+
this.#fsSync = runtime.fsSync;
|
|
71
|
+
this.#clock = runtime.clock;
|
|
72
|
+
this.#proc = runtime.proc;
|
|
73
|
+
this.#shutdownPromise = new Promise((r) => {
|
|
74
|
+
this.#resolveShutdown = r;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolves once a shutdown has been requested (via socket or signal). The
|
|
80
|
+
* daemon awaits this, then the bin translates it to `runtime.proc.exit(0)`.
|
|
81
|
+
* @returns {Promise<void>}
|
|
82
|
+
*/
|
|
83
|
+
whenStopped() {
|
|
84
|
+
return this.#shutdownPromise;
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
/**
|
|
@@ -90,13 +109,13 @@ export class SocketServer {
|
|
|
90
109
|
* @returns {string|null}
|
|
91
110
|
*/
|
|
92
111
|
#latestFileByMtime(dir, filter) {
|
|
93
|
-
const matches = readdirSync(dir).filter(filter);
|
|
112
|
+
const matches = this.#fsSync.readdirSync(dir).filter(filter);
|
|
94
113
|
if (matches.length === 0) return null;
|
|
95
114
|
let latest = join(dir, matches[0]);
|
|
96
|
-
let latestMtime = statSync(latest).mtimeMs;
|
|
115
|
+
let latestMtime = this.#fsSync.statSync(latest).mtimeMs;
|
|
97
116
|
for (let i = 1; i < matches.length; i++) {
|
|
98
117
|
const p = join(dir, matches[i]);
|
|
99
|
-
const mt = statSync(p).mtimeMs;
|
|
118
|
+
const mt = this.#fsSync.statSync(p).mtimeMs;
|
|
100
119
|
if (mt > latestMtime) {
|
|
101
120
|
latest = p;
|
|
102
121
|
latestMtime = mt;
|
|
@@ -113,7 +132,7 @@ export class SocketServer {
|
|
|
113
132
|
*/
|
|
114
133
|
#resolveBriefingFile(agentName, agentConfig) {
|
|
115
134
|
const stateDir = join(this.#cacheDir, "state");
|
|
116
|
-
if (existsSync(stateDir)) {
|
|
135
|
+
if (this.#fsSync.existsSync(stateDir)) {
|
|
117
136
|
const prefix = agentName.replace(/-/g, "_") + "_";
|
|
118
137
|
const found = this.#latestFileByMtime(
|
|
119
138
|
stateDir,
|
|
@@ -128,8 +147,9 @@ export class SocketServer {
|
|
|
128
147
|
"knowledge",
|
|
129
148
|
"Briefings",
|
|
130
149
|
);
|
|
131
|
-
if (existsSync(dir)) {
|
|
132
|
-
const files =
|
|
150
|
+
if (this.#fsSync.existsSync(dir)) {
|
|
151
|
+
const files = this.#fsSync
|
|
152
|
+
.readdirSync(dir)
|
|
133
153
|
.filter((f) => f.endsWith(".md"))
|
|
134
154
|
.sort()
|
|
135
155
|
.reverse();
|
|
@@ -143,10 +163,10 @@ export class SocketServer {
|
|
|
143
163
|
/**
|
|
144
164
|
* @param {import('node:net').Socket} socket
|
|
145
165
|
*/
|
|
146
|
-
#handleStatusRequest(socket) {
|
|
147
|
-
const config = this.#loadConfig();
|
|
148
|
-
const state = this.#stateManager.load();
|
|
149
|
-
const now =
|
|
166
|
+
async #handleStatusRequest(socket) {
|
|
167
|
+
const config = await this.#loadConfig();
|
|
168
|
+
const state = await this.#stateManager.load();
|
|
169
|
+
const now = nowFromClock(this.#clock);
|
|
150
170
|
const agents = {};
|
|
151
171
|
|
|
152
172
|
for (const [name, agent] of Object.entries(config.agents)) {
|
|
@@ -169,7 +189,7 @@ export class SocketServer {
|
|
|
169
189
|
this.#send(socket, {
|
|
170
190
|
type: "status",
|
|
171
191
|
uptime: this.#daemonStartedAt
|
|
172
|
-
? Math.floor((
|
|
192
|
+
? Math.floor((this.#clock.now() - this.#daemonStartedAt) / 1000)
|
|
173
193
|
: 0,
|
|
174
194
|
agents,
|
|
175
195
|
});
|
|
@@ -179,7 +199,7 @@ export class SocketServer {
|
|
|
179
199
|
* @param {import('node:net').Socket} socket
|
|
180
200
|
* @param {string} line
|
|
181
201
|
*/
|
|
182
|
-
#handleMessage(socket, line) {
|
|
202
|
+
async #handleMessage(socket, line) {
|
|
183
203
|
let request;
|
|
184
204
|
try {
|
|
185
205
|
request = JSON.parse(line);
|
|
@@ -194,8 +214,8 @@ export class SocketServer {
|
|
|
194
214
|
this.#log("Shutdown requested via socket.");
|
|
195
215
|
this.#send(socket, { type: "ack", command: "shutdown" });
|
|
196
216
|
socket.end();
|
|
197
|
-
this.#
|
|
198
|
-
|
|
217
|
+
this.#requestShutdown();
|
|
218
|
+
return;
|
|
199
219
|
}
|
|
200
220
|
|
|
201
221
|
if (request.type === "wake") {
|
|
@@ -203,7 +223,7 @@ export class SocketServer {
|
|
|
203
223
|
this.#send(socket, { type: "error", message: "Missing agent name" });
|
|
204
224
|
return;
|
|
205
225
|
}
|
|
206
|
-
const config = this.#loadConfig();
|
|
226
|
+
const config = await this.#loadConfig();
|
|
207
227
|
const agent = config.agents[request.agent];
|
|
208
228
|
if (!agent) {
|
|
209
229
|
this.#send(socket, {
|
|
@@ -217,7 +237,7 @@ export class SocketServer {
|
|
|
217
237
|
command: "wake",
|
|
218
238
|
agent: request.agent,
|
|
219
239
|
});
|
|
220
|
-
const state = this.#stateManager.load();
|
|
240
|
+
const state = await this.#stateManager.load();
|
|
221
241
|
this.#agentRunner
|
|
222
242
|
.wake(request.agent, agent, state, config.env)
|
|
223
243
|
.catch(() => {});
|
|
@@ -231,12 +251,27 @@ export class SocketServer {
|
|
|
231
251
|
}
|
|
232
252
|
|
|
233
253
|
/**
|
|
234
|
-
*
|
|
254
|
+
* Tear down active children and the listening socket, then signal the daemon
|
|
255
|
+
* (via `whenStopped`) that it is safe to exit. The bin owns the actual
|
|
256
|
+
* `runtime.proc.exit` call (design Decision 4).
|
|
257
|
+
*/
|
|
258
|
+
#requestShutdown() {
|
|
259
|
+
this.#agentRunner.killActiveChildren();
|
|
260
|
+
if (this.#server) this.#server.close();
|
|
261
|
+
try {
|
|
262
|
+
this.#fsSync.unlinkSync(this.#socketPath);
|
|
263
|
+
} catch {}
|
|
264
|
+
this.#resolveShutdown();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Remove any existing socket file, bind the server, and register
|
|
269
|
+
* SIGTERM/SIGINT handlers that request a graceful shutdown.
|
|
235
270
|
* @returns {import('node:net').Server}
|
|
236
271
|
*/
|
|
237
272
|
start() {
|
|
238
273
|
try {
|
|
239
|
-
unlinkSync(this.#socketPath);
|
|
274
|
+
this.#fsSync.unlinkSync(this.#socketPath);
|
|
240
275
|
} catch {}
|
|
241
276
|
|
|
242
277
|
this.#server = createServer((socket) => {
|
|
@@ -247,14 +282,14 @@ export class SocketServer {
|
|
|
247
282
|
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
248
283
|
const line = buffer.slice(0, idx).trim();
|
|
249
284
|
buffer = buffer.slice(idx + 1);
|
|
250
|
-
if (line) this.#handleMessage(socket, line);
|
|
285
|
+
if (line) void this.#handleMessage(socket, line);
|
|
251
286
|
}
|
|
252
287
|
});
|
|
253
288
|
socket.on("error", () => {});
|
|
254
289
|
});
|
|
255
290
|
|
|
256
291
|
this.#server.listen(this.#socketPath, () => {
|
|
257
|
-
chmodSync(this.#socketPath, 0o600);
|
|
292
|
+
this.#fsSync.chmodSync(this.#socketPath, 0o600);
|
|
258
293
|
this.#log(`Socket server listening on ${this.#socketPath}`);
|
|
259
294
|
});
|
|
260
295
|
|
|
@@ -262,16 +297,8 @@ export class SocketServer {
|
|
|
262
297
|
this.#log(`Socket server error: ${err.message}`);
|
|
263
298
|
});
|
|
264
299
|
|
|
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);
|
|
300
|
+
this.#proc.on("SIGTERM", () => this.#requestShutdown());
|
|
301
|
+
this.#proc.on("SIGINT", () => this.#requestShutdown());
|
|
275
302
|
|
|
276
303
|
return this.#server;
|
|
277
304
|
}
|
|
@@ -284,7 +311,7 @@ export class SocketServer {
|
|
|
284
311
|
this.#server.close();
|
|
285
312
|
}
|
|
286
313
|
try {
|
|
287
|
-
unlinkSync(this.#socketPath);
|
|
314
|
+
this.#fsSync.unlinkSync(this.#socketPath);
|
|
288
315
|
} catch {}
|
|
289
316
|
}
|
|
290
317
|
}
|
|
@@ -292,16 +319,20 @@ export class SocketServer {
|
|
|
292
319
|
/**
|
|
293
320
|
* Connect to the daemon socket and request graceful shutdown.
|
|
294
321
|
* @param {string} socketPath
|
|
322
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
323
|
+
* Injected runtime bag (uses `fsSync` and `clock`).
|
|
295
324
|
* @returns {Promise<boolean>}
|
|
296
325
|
*/
|
|
297
|
-
export async function requestShutdown(socketPath) {
|
|
298
|
-
if (!
|
|
326
|
+
export async function requestShutdown(socketPath, runtime) {
|
|
327
|
+
if (!runtime?.fsSync) throw new Error("runtime.fsSync is required");
|
|
328
|
+
if (!runtime?.clock) throw new Error("runtime.clock is required");
|
|
329
|
+
if (!runtime.fsSync.existsSync(socketPath)) {
|
|
299
330
|
logger.info("Daemon not running (no socket).");
|
|
300
331
|
return false;
|
|
301
332
|
}
|
|
302
333
|
|
|
303
334
|
return new Promise((resolve) => {
|
|
304
|
-
const timeout = setTimeout(() => {
|
|
335
|
+
const timeout = runtime.clock.setTimeout(() => {
|
|
305
336
|
logger.info("Shutdown timed out.");
|
|
306
337
|
socket.destroy();
|
|
307
338
|
resolve(false);
|
|
@@ -315,7 +346,7 @@ export async function requestShutdown(socketPath) {
|
|
|
315
346
|
socket.on("data", (data) => {
|
|
316
347
|
buffer += data.toString();
|
|
317
348
|
if (buffer.includes("\n")) {
|
|
318
|
-
clearTimeout(timeout);
|
|
349
|
+
runtime.clock.clearTimeout(timeout);
|
|
319
350
|
logger.info("Daemon stopped.");
|
|
320
351
|
socket.destroy();
|
|
321
352
|
resolve(true);
|
|
@@ -323,13 +354,13 @@ export async function requestShutdown(socketPath) {
|
|
|
323
354
|
});
|
|
324
355
|
|
|
325
356
|
socket.on("error", () => {
|
|
326
|
-
clearTimeout(timeout);
|
|
357
|
+
runtime.clock.clearTimeout(timeout);
|
|
327
358
|
logger.info("Daemon not running (connection refused).");
|
|
328
359
|
resolve(false);
|
|
329
360
|
});
|
|
330
361
|
|
|
331
362
|
socket.on("close", () => {
|
|
332
|
-
clearTimeout(timeout);
|
|
363
|
+
runtime.clock.clearTimeout(timeout);
|
|
333
364
|
resolve(true);
|
|
334
365
|
});
|
|
335
366
|
});
|
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
|
-
}
|