@orgloop/agentctl 1.1.0 → 1.2.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 +145 -3
- package/dist/adapters/claude-code.js +23 -12
- package/dist/adapters/codex.d.ts +72 -0
- package/dist/adapters/codex.js +702 -0
- package/dist/adapters/openclaw.d.ts +60 -9
- package/dist/adapters/openclaw.js +195 -38
- package/dist/adapters/opencode.d.ts +143 -0
- package/dist/adapters/opencode.js +682 -0
- package/dist/adapters/pi-rust.d.ts +89 -0
- package/dist/adapters/pi-rust.js +753 -0
- package/dist/adapters/pi.d.ts +96 -0
- package/dist/adapters/pi.js +865 -0
- package/dist/cli.js +332 -60
- package/dist/core/types.d.ts +1 -0
- package/dist/daemon/server.js +152 -21
- package/dist/daemon/session-tracker.d.ts +24 -0
- package/dist/daemon/session-tracker.js +149 -4
- package/dist/daemon/state.d.ts +1 -0
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +4 -0
- package/dist/launch-orchestrator.d.ts +60 -0
- package/dist/launch-orchestrator.js +198 -0
- package/dist/matrix-parser.d.ts +40 -0
- package/dist/matrix-parser.js +69 -0
- package/dist/utils/daemon-env.d.ts +16 -0
- package/dist/utils/daemon-env.js +85 -0
- package/dist/utils/partial-read.d.ts +20 -0
- package/dist/utils/partial-read.js +66 -0
- package/dist/utils/resolve-binary.d.ts +14 -0
- package/dist/utils/resolve-binary.js +66 -0
- package/dist/worktree.d.ts +22 -0
- package/dist/worktree.js +68 -0
- package/package.json +3 -2
package/dist/daemon/server.js
CHANGED
|
@@ -1,38 +1,57 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
1
2
|
import { EventEmitter } from "node:events";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import http from "node:http";
|
|
4
5
|
import net from "node:net";
|
|
5
6
|
import os from "node:os";
|
|
6
7
|
import path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
7
9
|
import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
10
|
+
import { CodexAdapter } from "../adapters/codex.js";
|
|
8
11
|
import { OpenClawAdapter } from "../adapters/openclaw.js";
|
|
12
|
+
import { OpenCodeAdapter } from "../adapters/opencode.js";
|
|
13
|
+
import { PiAdapter } from "../adapters/pi.js";
|
|
14
|
+
import { PiRustAdapter } from "../adapters/pi-rust.js";
|
|
9
15
|
import { migrateLocks } from "../migration/migrate-locks.js";
|
|
16
|
+
import { saveEnvironment } from "../utils/daemon-env.js";
|
|
17
|
+
import { clearBinaryCache } from "../utils/resolve-binary.js";
|
|
10
18
|
import { FuseEngine } from "./fuse-engine.js";
|
|
11
19
|
import { LockManager } from "./lock-manager.js";
|
|
12
20
|
import { MetricsRegistry } from "./metrics.js";
|
|
13
21
|
import { SessionTracker } from "./session-tracker.js";
|
|
14
22
|
import { StateManager } from "./state.js";
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
15
24
|
const startTime = Date.now();
|
|
16
25
|
export async function startDaemon(opts = {}) {
|
|
17
26
|
const configDir = opts.configDir || path.join(os.homedir(), ".agentctl");
|
|
18
27
|
await fs.mkdir(configDir, { recursive: true });
|
|
19
|
-
// 1. Check for existing daemon
|
|
20
28
|
const pidFilePath = path.join(configDir, "agentctl.pid");
|
|
21
|
-
const existingPid = await readPidFile(pidFilePath);
|
|
22
|
-
if (existingPid && isProcessAlive(existingPid)) {
|
|
23
|
-
throw new Error(`Daemon already running (PID ${existingPid})`);
|
|
24
|
-
}
|
|
25
|
-
// 2. Clean stale socket
|
|
26
29
|
const sockPath = path.join(configDir, "agentctl.sock");
|
|
30
|
+
// 1. Kill stale daemon/supervisor processes before anything else (#39)
|
|
31
|
+
await killStaleDaemons(configDir);
|
|
32
|
+
// 2. Verify no daemon is actually running by trying to connect to socket
|
|
33
|
+
const socketAlive = await isSocketAlive(sockPath);
|
|
34
|
+
if (socketAlive) {
|
|
35
|
+
throw new Error("Daemon already running (socket responsive)");
|
|
36
|
+
}
|
|
37
|
+
// 3. Clean stale socket file
|
|
27
38
|
await fs.rm(sockPath, { force: true });
|
|
28
|
-
//
|
|
39
|
+
// 4. Save shell environment for subprocess spawning (#42)
|
|
40
|
+
await saveEnvironment(configDir);
|
|
41
|
+
// 5. Clear binary cache on restart (#41 — pick up moved/updated binaries)
|
|
42
|
+
clearBinaryCache();
|
|
43
|
+
// 6. Run migration (idempotent)
|
|
29
44
|
await migrateLocks(configDir).catch((err) => console.error("Migration warning:", err.message));
|
|
30
|
-
//
|
|
45
|
+
// 7. Load persisted state
|
|
31
46
|
const state = await StateManager.load(configDir);
|
|
32
|
-
//
|
|
47
|
+
// 8. Initialize subsystems
|
|
33
48
|
const adapters = opts.adapters || {
|
|
34
49
|
"claude-code": new ClaudeCodeAdapter(),
|
|
50
|
+
codex: new CodexAdapter(),
|
|
35
51
|
openclaw: new OpenClawAdapter(),
|
|
52
|
+
opencode: new OpenCodeAdapter(),
|
|
53
|
+
pi: new PiAdapter(),
|
|
54
|
+
"pi-rust": new PiRustAdapter(),
|
|
36
55
|
};
|
|
37
56
|
const lockManager = new LockManager(state);
|
|
38
57
|
const emitter = new EventEmitter();
|
|
@@ -46,11 +65,13 @@ export async function startDaemon(opts = {}) {
|
|
|
46
65
|
emitter.on("fuse.fired", () => {
|
|
47
66
|
metrics.recordFuseFired();
|
|
48
67
|
});
|
|
49
|
-
//
|
|
68
|
+
// 9. Validate all sessions on startup — mark dead ones as stopped (#40)
|
|
69
|
+
sessionTracker.validateAllSessions();
|
|
70
|
+
// 10. Resume fuse timers
|
|
50
71
|
fuseEngine.resumeTimers();
|
|
51
|
-
//
|
|
72
|
+
// 11. Start session polling
|
|
52
73
|
sessionTracker.startPolling();
|
|
53
|
-
//
|
|
74
|
+
// 12. Create request handler
|
|
54
75
|
const handleRequest = createRequestHandler({
|
|
55
76
|
sessionTracker,
|
|
56
77
|
lockManager,
|
|
@@ -61,7 +82,7 @@ export async function startDaemon(opts = {}) {
|
|
|
61
82
|
configDir,
|
|
62
83
|
sockPath,
|
|
63
84
|
});
|
|
64
|
-
//
|
|
85
|
+
// 13. Start Unix socket server
|
|
65
86
|
const socketServer = net.createServer((conn) => {
|
|
66
87
|
let buffer = "";
|
|
67
88
|
conn.on("data", (chunk) => {
|
|
@@ -97,7 +118,9 @@ export async function startDaemon(opts = {}) {
|
|
|
97
118
|
socketServer.listen(sockPath, () => resolve());
|
|
98
119
|
socketServer.on("error", reject);
|
|
99
120
|
});
|
|
100
|
-
//
|
|
121
|
+
// 14. Write PID file (after socket is listening — acts as "lock acquired")
|
|
122
|
+
await fs.writeFile(pidFilePath, String(process.pid));
|
|
123
|
+
// 15. Start HTTP metrics server
|
|
101
124
|
const metricsPort = opts.metricsPort ?? 9200;
|
|
102
125
|
const httpServer = http.createServer((req, res) => {
|
|
103
126
|
if (req.url === "/metrics" && req.method === "GET") {
|
|
@@ -115,8 +138,6 @@ export async function startDaemon(opts = {}) {
|
|
|
115
138
|
httpServer.listen(metricsPort, "127.0.0.1", () => resolve());
|
|
116
139
|
httpServer.on("error", reject);
|
|
117
140
|
});
|
|
118
|
-
// 11. Write PID file
|
|
119
|
-
await fs.writeFile(pidFilePath, String(process.pid));
|
|
120
141
|
// Shutdown function
|
|
121
142
|
const shutdown = async () => {
|
|
122
143
|
sessionTracker.stopPolling();
|
|
@@ -128,7 +149,7 @@ export async function startDaemon(opts = {}) {
|
|
|
128
149
|
await fs.rm(sockPath, { force: true });
|
|
129
150
|
await fs.rm(pidFilePath, { force: true });
|
|
130
151
|
};
|
|
131
|
-
//
|
|
152
|
+
// 16. Signal handlers
|
|
132
153
|
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
133
154
|
process.on(sig, async () => {
|
|
134
155
|
console.log(`Received ${sig}, shutting down...`);
|
|
@@ -141,15 +162,100 @@ export async function startDaemon(opts = {}) {
|
|
|
141
162
|
console.log(` Metrics: http://localhost:${metricsPort}/metrics`);
|
|
142
163
|
return { socketServer, httpServer, shutdown };
|
|
143
164
|
}
|
|
165
|
+
// --- Stale daemon cleanup (#39) ---
|
|
166
|
+
/**
|
|
167
|
+
* Find and kill ALL stale agentctl daemon/supervisor processes.
|
|
168
|
+
* This ensures singleton enforcement even after unclean shutdowns.
|
|
169
|
+
*/
|
|
170
|
+
async function killStaleDaemons(configDir) {
|
|
171
|
+
// 1. Kill processes recorded in PID files
|
|
172
|
+
for (const pidFile of ["agentctl.pid", "supervisor.pid"]) {
|
|
173
|
+
const p = path.join(configDir, pidFile);
|
|
174
|
+
const pid = await readPidFile(p);
|
|
175
|
+
if (pid && pid !== process.pid && isProcessAlive(pid)) {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, "SIGTERM");
|
|
178
|
+
// Wait briefly for clean shutdown
|
|
179
|
+
await sleep(500);
|
|
180
|
+
if (isProcessAlive(pid)) {
|
|
181
|
+
process.kill(pid, "SIGKILL");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Already gone
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Clean up stale PID file
|
|
189
|
+
await fs.rm(p, { force: true }).catch(() => { });
|
|
190
|
+
}
|
|
191
|
+
// 2. Scan ps for any remaining agentctl daemon processes
|
|
192
|
+
try {
|
|
193
|
+
const { stdout } = await execFileAsync("ps", ["aux"]);
|
|
194
|
+
for (const line of stdout.split("\n")) {
|
|
195
|
+
if (!line.includes("agentctl") || !line.includes("daemon"))
|
|
196
|
+
continue;
|
|
197
|
+
if (line.includes("grep"))
|
|
198
|
+
continue;
|
|
199
|
+
const fields = line.trim().split(/\s+/);
|
|
200
|
+
if (fields.length < 2)
|
|
201
|
+
continue;
|
|
202
|
+
const pid = Number.parseInt(fields[1], 10);
|
|
203
|
+
if (Number.isNaN(pid) || pid === process.pid)
|
|
204
|
+
continue;
|
|
205
|
+
// Also skip our parent process (supervisor)
|
|
206
|
+
if (pid === process.ppid)
|
|
207
|
+
continue;
|
|
208
|
+
try {
|
|
209
|
+
process.kill(pid, "SIGTERM");
|
|
210
|
+
await sleep(200);
|
|
211
|
+
if (isProcessAlive(pid)) {
|
|
212
|
+
process.kill(pid, "SIGKILL");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Already gone
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// ps failed — best effort
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Check if a Unix socket is actually responsive (not just a stale file).
|
|
226
|
+
*/
|
|
227
|
+
async function isSocketAlive(sockPath) {
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
const socket = net.createConnection(sockPath);
|
|
230
|
+
const timeout = setTimeout(() => {
|
|
231
|
+
socket.destroy();
|
|
232
|
+
resolve(false);
|
|
233
|
+
}, 1000);
|
|
234
|
+
socket.on("connect", () => {
|
|
235
|
+
clearTimeout(timeout);
|
|
236
|
+
socket.destroy();
|
|
237
|
+
resolve(true);
|
|
238
|
+
});
|
|
239
|
+
socket.on("error", () => {
|
|
240
|
+
clearTimeout(timeout);
|
|
241
|
+
resolve(false);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
144
245
|
function createRequestHandler(ctx) {
|
|
145
246
|
return async (req) => {
|
|
146
247
|
const params = (req.params || {});
|
|
147
248
|
switch (req.method) {
|
|
148
|
-
case "session.list":
|
|
149
|
-
|
|
249
|
+
case "session.list": {
|
|
250
|
+
let sessions = ctx.sessionTracker.listSessions({
|
|
150
251
|
status: params.status,
|
|
151
252
|
all: params.all,
|
|
152
253
|
});
|
|
254
|
+
if (params.group) {
|
|
255
|
+
sessions = sessions.filter((s) => s.group === params.group);
|
|
256
|
+
}
|
|
257
|
+
return sessions;
|
|
258
|
+
}
|
|
153
259
|
case "session.status": {
|
|
154
260
|
const session = ctx.sessionTracker.getSession(params.id);
|
|
155
261
|
if (!session)
|
|
@@ -157,11 +263,15 @@ function createRequestHandler(ctx) {
|
|
|
157
263
|
return session;
|
|
158
264
|
}
|
|
159
265
|
case "session.peek": {
|
|
160
|
-
|
|
266
|
+
// Auto-detect adapter from tracked session, fall back to param or claude-code
|
|
267
|
+
const tracked = ctx.sessionTracker.getSession(params.id);
|
|
268
|
+
const adapterName = params.adapter || tracked?.adapter || "claude-code";
|
|
161
269
|
const adapter = ctx.adapters[adapterName];
|
|
162
270
|
if (!adapter)
|
|
163
271
|
throw new Error(`Unknown adapter: ${adapterName}`);
|
|
164
|
-
|
|
272
|
+
// Use the full session ID if we resolved it from the tracker
|
|
273
|
+
const peekId = tracked?.id || params.id;
|
|
274
|
+
return adapter.peek(peekId, {
|
|
165
275
|
lines: params.lines,
|
|
166
276
|
});
|
|
167
277
|
}
|
|
@@ -193,6 +303,10 @@ function createRequestHandler(ctx) {
|
|
|
193
303
|
env: params.env,
|
|
194
304
|
adapterOpts: params.adapterOpts,
|
|
195
305
|
});
|
|
306
|
+
// Propagate group tag if provided
|
|
307
|
+
if (params.group) {
|
|
308
|
+
session.group = params.group;
|
|
309
|
+
}
|
|
196
310
|
const record = ctx.sessionTracker.track(session, adapterName);
|
|
197
311
|
// Auto-lock
|
|
198
312
|
if (cwd) {
|
|
@@ -204,6 +318,15 @@ function createRequestHandler(ctx) {
|
|
|
204
318
|
const session = ctx.sessionTracker.getSession(params.id);
|
|
205
319
|
if (!session)
|
|
206
320
|
throw new Error(`Session not found: ${params.id}`);
|
|
321
|
+
// Ghost pending entry with dead PID: remove from state with --force
|
|
322
|
+
if (session.id.startsWith("pending-") &&
|
|
323
|
+
params.force &&
|
|
324
|
+
session.pid &&
|
|
325
|
+
!isProcessAlive(session.pid)) {
|
|
326
|
+
ctx.lockManager.autoUnlock(session.id);
|
|
327
|
+
ctx.sessionTracker.removeSession(session.id);
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
207
330
|
const adapter = ctx.adapters[session.adapter];
|
|
208
331
|
if (!adapter)
|
|
209
332
|
throw new Error(`Unknown adapter: ${session.adapter}`);
|
|
@@ -230,6 +353,11 @@ function createRequestHandler(ctx) {
|
|
|
230
353
|
await adapter.resume(session.id, params.message);
|
|
231
354
|
return null;
|
|
232
355
|
}
|
|
356
|
+
// --- Prune command (#40) ---
|
|
357
|
+
case "session.prune": {
|
|
358
|
+
const pruned = ctx.sessionTracker.pruneDeadSessions();
|
|
359
|
+
return { pruned };
|
|
360
|
+
}
|
|
233
361
|
case "lock.list":
|
|
234
362
|
return ctx.lockManager.listAll();
|
|
235
363
|
case "lock.acquire":
|
|
@@ -281,3 +409,6 @@ function isProcessAlive(pid) {
|
|
|
281
409
|
return false;
|
|
282
410
|
}
|
|
283
411
|
}
|
|
412
|
+
function sleep(ms) {
|
|
413
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
414
|
+
}
|
|
@@ -11,9 +11,12 @@ export declare class SessionTracker {
|
|
|
11
11
|
private adapters;
|
|
12
12
|
private pollIntervalMs;
|
|
13
13
|
private pollHandle;
|
|
14
|
+
private polling;
|
|
14
15
|
private readonly isProcessAlive;
|
|
15
16
|
constructor(state: StateManager, opts: SessionTrackerOpts);
|
|
16
17
|
startPolling(): void;
|
|
18
|
+
/** Run poll() with a guard to skip if the previous cycle is still running */
|
|
19
|
+
private guardedPoll;
|
|
17
20
|
stopPolling(): void;
|
|
18
21
|
private poll;
|
|
19
22
|
/**
|
|
@@ -22,6 +25,24 @@ export declare class SessionTracker {
|
|
|
22
25
|
* - Any "running"/"idle" session in state whose PID is dead → mark stopped
|
|
23
26
|
*/
|
|
24
27
|
private reapStaleEntries;
|
|
28
|
+
/**
|
|
29
|
+
* Validate all sessions on daemon startup (#40).
|
|
30
|
+
* Any session marked as "running" or "idle" whose PID is dead gets
|
|
31
|
+
* immediately marked as "stopped". This prevents unbounded growth of
|
|
32
|
+
* ghost sessions across daemon restarts.
|
|
33
|
+
*/
|
|
34
|
+
validateAllSessions(): void;
|
|
35
|
+
/**
|
|
36
|
+
* Aggressively prune all clearly-dead sessions (#40).
|
|
37
|
+
* Returns the number of sessions pruned.
|
|
38
|
+
* Called via `agentctl prune` command.
|
|
39
|
+
*/
|
|
40
|
+
pruneDeadSessions(): number;
|
|
41
|
+
/**
|
|
42
|
+
* Remove stopped sessions from state that have been stopped for more than 7 days.
|
|
43
|
+
* This reduces overhead from accumulating hundreds of historical sessions.
|
|
44
|
+
*/
|
|
45
|
+
private pruneOldSessions;
|
|
25
46
|
/** Track a newly launched session */
|
|
26
47
|
track(session: AgentSession, adapterName: string): SessionRecord;
|
|
27
48
|
/** Get session record by id (exact or prefix) */
|
|
@@ -30,8 +51,11 @@ export declare class SessionTracker {
|
|
|
30
51
|
listSessions(opts?: {
|
|
31
52
|
status?: string;
|
|
32
53
|
all?: boolean;
|
|
54
|
+
adapter?: string;
|
|
33
55
|
}): SessionRecord[];
|
|
34
56
|
activeCount(): number;
|
|
57
|
+
/** Remove a session from state entirely (used for ghost cleanup) */
|
|
58
|
+
removeSession(sessionId: string): void;
|
|
35
59
|
/** Called when a session stops — returns the cwd for fuse/lock processing */
|
|
36
60
|
onSessionExit(sessionId: string): SessionRecord | undefined;
|
|
37
61
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
/** Max age for stopped sessions in state before pruning (7 days) */
|
|
2
|
+
const STOPPED_SESSION_PRUNE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
1
3
|
export class SessionTracker {
|
|
2
4
|
state;
|
|
3
5
|
adapters;
|
|
4
6
|
pollIntervalMs;
|
|
5
7
|
pollHandle = null;
|
|
8
|
+
polling = false;
|
|
6
9
|
isProcessAlive;
|
|
7
10
|
constructor(state, opts) {
|
|
8
11
|
this.state = state;
|
|
@@ -13,12 +16,25 @@ export class SessionTracker {
|
|
|
13
16
|
startPolling() {
|
|
14
17
|
if (this.pollHandle)
|
|
15
18
|
return;
|
|
19
|
+
// Prune old stopped sessions on startup
|
|
20
|
+
this.pruneOldSessions();
|
|
16
21
|
// Initial poll
|
|
17
|
-
this.
|
|
22
|
+
this.guardedPoll();
|
|
18
23
|
this.pollHandle = setInterval(() => {
|
|
19
|
-
this.
|
|
24
|
+
this.guardedPoll();
|
|
20
25
|
}, this.pollIntervalMs);
|
|
21
26
|
}
|
|
27
|
+
/** Run poll() with a guard to skip if the previous cycle is still running */
|
|
28
|
+
guardedPoll() {
|
|
29
|
+
if (this.polling)
|
|
30
|
+
return;
|
|
31
|
+
this.polling = true;
|
|
32
|
+
this.poll()
|
|
33
|
+
.catch((err) => console.error("Poll error:", err))
|
|
34
|
+
.finally(() => {
|
|
35
|
+
this.polling = false;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
22
38
|
stopPolling() {
|
|
23
39
|
if (this.pollHandle) {
|
|
24
40
|
clearInterval(this.pollHandle);
|
|
@@ -40,14 +56,17 @@ export class SessionTracker {
|
|
|
40
56
|
if (!existing) {
|
|
41
57
|
this.state.setSession(session.id, record);
|
|
42
58
|
}
|
|
43
|
-
else if (existing.status !== record.status
|
|
44
|
-
|
|
59
|
+
else if (existing.status !== record.status ||
|
|
60
|
+
(!existing.model && record.model)) {
|
|
61
|
+
// Status changed or model resolved — update
|
|
45
62
|
this.state.setSession(session.id, {
|
|
46
63
|
...existing,
|
|
47
64
|
status: record.status,
|
|
48
65
|
stoppedAt: record.stoppedAt,
|
|
66
|
+
model: record.model || existing.model,
|
|
49
67
|
tokens: record.tokens,
|
|
50
68
|
cost: record.cost,
|
|
69
|
+
prompt: record.prompt || existing.prompt,
|
|
51
70
|
});
|
|
52
71
|
}
|
|
53
72
|
}
|
|
@@ -94,9 +113,117 @@ export class SessionTracker {
|
|
|
94
113
|
}
|
|
95
114
|
}
|
|
96
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Validate all sessions on daemon startup (#40).
|
|
118
|
+
* Any session marked as "running" or "idle" whose PID is dead gets
|
|
119
|
+
* immediately marked as "stopped". This prevents unbounded growth of
|
|
120
|
+
* ghost sessions across daemon restarts.
|
|
121
|
+
*/
|
|
122
|
+
validateAllSessions() {
|
|
123
|
+
const sessions = this.state.getSessions();
|
|
124
|
+
let cleaned = 0;
|
|
125
|
+
for (const [id, record] of Object.entries(sessions)) {
|
|
126
|
+
if (record.status !== "running" && record.status !== "idle")
|
|
127
|
+
continue;
|
|
128
|
+
if (record.pid) {
|
|
129
|
+
if (!this.isProcessAlive(record.pid)) {
|
|
130
|
+
this.state.setSession(id, {
|
|
131
|
+
...record,
|
|
132
|
+
status: "stopped",
|
|
133
|
+
stoppedAt: new Date().toISOString(),
|
|
134
|
+
});
|
|
135
|
+
cleaned++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
// No PID recorded — can't verify, mark as stopped
|
|
140
|
+
this.state.setSession(id, {
|
|
141
|
+
...record,
|
|
142
|
+
status: "stopped",
|
|
143
|
+
stoppedAt: new Date().toISOString(),
|
|
144
|
+
});
|
|
145
|
+
cleaned++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (cleaned > 0) {
|
|
149
|
+
console.error(`Validated sessions on startup: marked ${cleaned} dead sessions as stopped`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Aggressively prune all clearly-dead sessions (#40).
|
|
154
|
+
* Returns the number of sessions pruned.
|
|
155
|
+
* Called via `agentctl prune` command.
|
|
156
|
+
*/
|
|
157
|
+
pruneDeadSessions() {
|
|
158
|
+
const sessions = this.state.getSessions();
|
|
159
|
+
let pruned = 0;
|
|
160
|
+
for (const [id, record] of Object.entries(sessions)) {
|
|
161
|
+
// Remove stopped/completed/failed sessions older than 24h
|
|
162
|
+
if (record.status === "stopped" ||
|
|
163
|
+
record.status === "completed" ||
|
|
164
|
+
record.status === "failed") {
|
|
165
|
+
const stoppedAt = record.stoppedAt
|
|
166
|
+
? new Date(record.stoppedAt).getTime()
|
|
167
|
+
: new Date(record.startedAt).getTime();
|
|
168
|
+
const age = Date.now() - stoppedAt;
|
|
169
|
+
if (age > 24 * 60 * 60 * 1000) {
|
|
170
|
+
this.state.removeSession(id);
|
|
171
|
+
pruned++;
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
// Remove running/idle sessions whose PID is dead
|
|
176
|
+
if (record.status === "running" || record.status === "idle") {
|
|
177
|
+
if (record.pid && !this.isProcessAlive(record.pid)) {
|
|
178
|
+
this.state.removeSession(id);
|
|
179
|
+
pruned++;
|
|
180
|
+
}
|
|
181
|
+
else if (!record.pid) {
|
|
182
|
+
this.state.removeSession(id);
|
|
183
|
+
pruned++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return pruned;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Remove stopped sessions from state that have been stopped for more than 7 days.
|
|
191
|
+
* This reduces overhead from accumulating hundreds of historical sessions.
|
|
192
|
+
*/
|
|
193
|
+
pruneOldSessions() {
|
|
194
|
+
const sessions = this.state.getSessions();
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
let pruned = 0;
|
|
197
|
+
for (const [id, record] of Object.entries(sessions)) {
|
|
198
|
+
if (record.status !== "stopped" &&
|
|
199
|
+
record.status !== "completed" &&
|
|
200
|
+
record.status !== "failed") {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const stoppedAt = record.stoppedAt
|
|
204
|
+
? new Date(record.stoppedAt).getTime()
|
|
205
|
+
: new Date(record.startedAt).getTime();
|
|
206
|
+
if (now - stoppedAt > STOPPED_SESSION_PRUNE_AGE_MS) {
|
|
207
|
+
this.state.removeSession(id);
|
|
208
|
+
pruned++;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (pruned > 0) {
|
|
212
|
+
console.error(`Pruned ${pruned} sessions stopped >7 days ago from state`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
97
215
|
/** Track a newly launched session */
|
|
98
216
|
track(session, adapterName) {
|
|
99
217
|
const record = sessionToRecord(session, adapterName);
|
|
218
|
+
// Pending→UUID reconciliation: if this is a real session (not pending),
|
|
219
|
+
// remove any pending-PID placeholder with the same PID
|
|
220
|
+
if (!session.id.startsWith("pending-") && session.pid) {
|
|
221
|
+
for (const [id, existing] of Object.entries(this.state.getSessions())) {
|
|
222
|
+
if (id.startsWith("pending-") && existing.pid === session.pid) {
|
|
223
|
+
this.state.removeSession(id);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
100
227
|
this.state.setSession(session.id, record);
|
|
101
228
|
return record;
|
|
102
229
|
}
|
|
@@ -116,7 +243,20 @@ export class SessionTracker {
|
|
|
116
243
|
/** List all tracked sessions */
|
|
117
244
|
listSessions(opts) {
|
|
118
245
|
const sessions = Object.values(this.state.getSessions());
|
|
246
|
+
// Liveness check: mark sessions with dead PIDs as stopped
|
|
247
|
+
for (const s of sessions) {
|
|
248
|
+
if ((s.status === "running" || s.status === "idle") && s.pid) {
|
|
249
|
+
if (!this.isProcessAlive(s.pid)) {
|
|
250
|
+
s.status = "stopped";
|
|
251
|
+
s.stoppedAt = new Date().toISOString();
|
|
252
|
+
this.state.setSession(s.id, s);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
119
256
|
let filtered = sessions;
|
|
257
|
+
if (opts?.adapter) {
|
|
258
|
+
filtered = filtered.filter((s) => s.adapter === opts.adapter);
|
|
259
|
+
}
|
|
120
260
|
if (opts?.status) {
|
|
121
261
|
filtered = filtered.filter((s) => s.status === opts.status);
|
|
122
262
|
}
|
|
@@ -137,6 +277,10 @@ export class SessionTracker {
|
|
|
137
277
|
activeCount() {
|
|
138
278
|
return Object.values(this.state.getSessions()).filter((s) => s.status === "running" || s.status === "idle").length;
|
|
139
279
|
}
|
|
280
|
+
/** Remove a session from state entirely (used for ghost cleanup) */
|
|
281
|
+
removeSession(sessionId) {
|
|
282
|
+
this.state.removeSession(sessionId);
|
|
283
|
+
}
|
|
140
284
|
/** Called when a session stops — returns the cwd for fuse/lock processing */
|
|
141
285
|
onSessionExit(sessionId) {
|
|
142
286
|
const session = this.state.getSession(sessionId);
|
|
@@ -190,6 +334,7 @@ function sessionToRecord(session, adapterName) {
|
|
|
190
334
|
tokens: session.tokens,
|
|
191
335
|
cost: session.cost,
|
|
192
336
|
pid: session.pid,
|
|
337
|
+
group: session.group,
|
|
193
338
|
meta: session.meta,
|
|
194
339
|
};
|
|
195
340
|
}
|
package/dist/daemon/state.d.ts
CHANGED
package/dist/hooks.d.ts
CHANGED
package/dist/hooks.js
CHANGED
|
@@ -23,6 +23,10 @@ export async function runHook(hooks, phase, ctx) {
|
|
|
23
23
|
env.AGENTCTL_BRANCH = ctx.branch;
|
|
24
24
|
if (ctx.exitCode != null)
|
|
25
25
|
env.AGENTCTL_EXIT_CODE = String(ctx.exitCode);
|
|
26
|
+
if (ctx.group)
|
|
27
|
+
env.AGENTCTL_GROUP = ctx.group;
|
|
28
|
+
if (ctx.model)
|
|
29
|
+
env.AGENTCTL_MODEL = ctx.model;
|
|
26
30
|
try {
|
|
27
31
|
const result = await execAsync(script, {
|
|
28
32
|
cwd: ctx.cwd,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AgentAdapter, LifecycleHooks } from "./core/types.js";
|
|
2
|
+
/** A single adapter+model slot parsed from CLI flags */
|
|
3
|
+
export interface AdapterSlot {
|
|
4
|
+
adapter: string;
|
|
5
|
+
model?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Result of launching one slot within a group */
|
|
8
|
+
export interface SlotLaunchResult {
|
|
9
|
+
slot: AdapterSlot;
|
|
10
|
+
sessionId: string;
|
|
11
|
+
pid?: number;
|
|
12
|
+
cwd: string;
|
|
13
|
+
branch: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Result of the full orchestrated launch */
|
|
17
|
+
export interface OrchestratedLaunchResult {
|
|
18
|
+
groupId: string;
|
|
19
|
+
results: SlotLaunchResult[];
|
|
20
|
+
}
|
|
21
|
+
export interface OrchestrateOpts {
|
|
22
|
+
slots: AdapterSlot[];
|
|
23
|
+
prompt: string;
|
|
24
|
+
spec?: string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
hooks?: LifecycleHooks;
|
|
27
|
+
adapters: Record<string, AgentAdapter>;
|
|
28
|
+
/** Optional: callback when daemon is available for lock/track */
|
|
29
|
+
onSessionLaunched?: (result: SlotLaunchResult) => void;
|
|
30
|
+
/** Optional: callback when group ID is generated (before launches) */
|
|
31
|
+
onGroupCreated?: (groupId: string) => void;
|
|
32
|
+
}
|
|
33
|
+
/** Generate a short group ID like "g-a1b2c3" */
|
|
34
|
+
export declare function generateGroupId(): string;
|
|
35
|
+
/**
|
|
36
|
+
* Generate a short suffix for a slot, used in worktree/branch naming.
|
|
37
|
+
* When an adapter appears multiple times, disambiguate using the model short name.
|
|
38
|
+
*/
|
|
39
|
+
export declare function slotSuffix(slot: AdapterSlot, allSlots: AdapterSlot[]): string;
|
|
40
|
+
/** Build worktree path: <repo>-<groupId>-<suffix> */
|
|
41
|
+
export declare function worktreePath(repo: string, groupId: string, suffix: string): string;
|
|
42
|
+
/** Build branch name: try/<groupId>/<suffix> */
|
|
43
|
+
export declare function branchName(groupId: string, suffix: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Orchestrate a parallel multi-adapter launch.
|
|
46
|
+
*
|
|
47
|
+
* 1. Generate group ID
|
|
48
|
+
* 2. For each slot: create worktree, run on_worktree_create hook, launch adapter
|
|
49
|
+
* 3. Return all results (successes and failures)
|
|
50
|
+
*/
|
|
51
|
+
export declare function orchestrateLaunch(opts: OrchestrateOpts): Promise<OrchestratedLaunchResult>;
|
|
52
|
+
/**
|
|
53
|
+
* Parse positional adapter slots from raw argv.
|
|
54
|
+
*
|
|
55
|
+
* Multiple --adapter flags, each optionally followed by --model:
|
|
56
|
+
* --adapter claude-code --model opus --adapter codex
|
|
57
|
+
*
|
|
58
|
+
* Returns AdapterSlot[] representing each launch slot.
|
|
59
|
+
*/
|
|
60
|
+
export declare function parseAdapterSlots(rawArgs: string[]): AdapterSlot[];
|