@orgloop/agentctl 1.0.1 → 1.1.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.
|
@@ -148,7 +148,7 @@ export class ClaudeCodeAdapter {
|
|
|
148
148
|
if (pid) {
|
|
149
149
|
resolvedSessionId = await this.pollForSessionId(logPath, pid, 5000);
|
|
150
150
|
}
|
|
151
|
-
const sessionId = resolvedSessionId || crypto.randomUUID();
|
|
151
|
+
const sessionId = resolvedSessionId || (pid ? `pending-${pid}` : crypto.randomUUID());
|
|
152
152
|
// Persist session metadata so status checks work after wrapper exits
|
|
153
153
|
if (pid) {
|
|
154
154
|
await this.writeSessionMeta({
|
|
@@ -3,16 +3,25 @@ import type { SessionRecord, StateManager } from "./state.js";
|
|
|
3
3
|
export interface SessionTrackerOpts {
|
|
4
4
|
adapters: Record<string, AgentAdapter>;
|
|
5
5
|
pollIntervalMs?: number;
|
|
6
|
+
/** Override PID liveness check for testing (default: process.kill(pid, 0)) */
|
|
7
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
6
8
|
}
|
|
7
9
|
export declare class SessionTracker {
|
|
8
10
|
private state;
|
|
9
11
|
private adapters;
|
|
10
12
|
private pollIntervalMs;
|
|
11
13
|
private pollHandle;
|
|
14
|
+
private readonly isProcessAlive;
|
|
12
15
|
constructor(state: StateManager, opts: SessionTrackerOpts);
|
|
13
16
|
startPolling(): void;
|
|
14
17
|
stopPolling(): void;
|
|
15
18
|
private poll;
|
|
19
|
+
/**
|
|
20
|
+
* Clean up ghost sessions in the daemon state:
|
|
21
|
+
* - pending-* entries whose PID matches a resolved session → remove pending
|
|
22
|
+
* - Any "running"/"idle" session in state whose PID is dead → mark stopped
|
|
23
|
+
*/
|
|
24
|
+
private reapStaleEntries;
|
|
16
25
|
/** Track a newly launched session */
|
|
17
26
|
track(session: AgentSession, adapterName: string): SessionRecord;
|
|
18
27
|
/** Get session record by id (exact or prefix) */
|
|
@@ -3,10 +3,12 @@ export class SessionTracker {
|
|
|
3
3
|
adapters;
|
|
4
4
|
pollIntervalMs;
|
|
5
5
|
pollHandle = null;
|
|
6
|
+
isProcessAlive;
|
|
6
7
|
constructor(state, opts) {
|
|
7
8
|
this.state = state;
|
|
8
9
|
this.adapters = opts.adapters;
|
|
9
10
|
this.pollIntervalMs = opts.pollIntervalMs ?? 5000;
|
|
11
|
+
this.isProcessAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
|
|
10
12
|
}
|
|
11
13
|
startPolling() {
|
|
12
14
|
if (this.pollHandle)
|
|
@@ -24,10 +26,15 @@ export class SessionTracker {
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
async poll() {
|
|
29
|
+
// Collect PIDs from all adapter-returned sessions (the source of truth)
|
|
30
|
+
const adapterPidToId = new Map();
|
|
27
31
|
for (const [adapterName, adapter] of Object.entries(this.adapters)) {
|
|
28
32
|
try {
|
|
29
33
|
const sessions = await adapter.list({ all: true });
|
|
30
34
|
for (const session of sessions) {
|
|
35
|
+
if (session.pid) {
|
|
36
|
+
adapterPidToId.set(session.pid, session.id);
|
|
37
|
+
}
|
|
31
38
|
const existing = this.state.getSession(session.id);
|
|
32
39
|
const record = sessionToRecord(session, adapterName);
|
|
33
40
|
if (!existing) {
|
|
@@ -49,6 +56,43 @@ export class SessionTracker {
|
|
|
49
56
|
// Adapter unavailable — skip
|
|
50
57
|
}
|
|
51
58
|
}
|
|
59
|
+
// Reap stale entries from daemon state
|
|
60
|
+
this.reapStaleEntries(adapterPidToId);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Clean up ghost sessions in the daemon state:
|
|
64
|
+
* - pending-* entries whose PID matches a resolved session → remove pending
|
|
65
|
+
* - Any "running"/"idle" session in state whose PID is dead → mark stopped
|
|
66
|
+
*/
|
|
67
|
+
reapStaleEntries(adapterPidToId) {
|
|
68
|
+
const sessions = this.state.getSessions();
|
|
69
|
+
for (const [id, record] of Object.entries(sessions)) {
|
|
70
|
+
// Bug 2: If this is a pending-* entry and a real session has the same PID,
|
|
71
|
+
// the pending entry is stale — remove it
|
|
72
|
+
if (id.startsWith("pending-") && record.pid) {
|
|
73
|
+
const resolvedId = adapterPidToId.get(record.pid);
|
|
74
|
+
if (resolvedId && resolvedId !== id) {
|
|
75
|
+
this.state.removeSession(id);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Bug 1: If session is "running"/"idle" but PID is dead, mark stopped
|
|
80
|
+
if ((record.status === "running" || record.status === "idle") &&
|
|
81
|
+
record.pid) {
|
|
82
|
+
// Only reap if the adapter didn't return this session as running
|
|
83
|
+
// (adapter is the source of truth for sessions it knows about)
|
|
84
|
+
const adapterId = adapterPidToId.get(record.pid);
|
|
85
|
+
if (adapterId === id)
|
|
86
|
+
continue; // Adapter confirmed this PID is active
|
|
87
|
+
if (!this.isProcessAlive(record.pid)) {
|
|
88
|
+
this.state.setSession(id, {
|
|
89
|
+
...record,
|
|
90
|
+
status: "stopped",
|
|
91
|
+
stoppedAt: new Date().toISOString(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
52
96
|
}
|
|
53
97
|
/** Track a newly launched session */
|
|
54
98
|
track(session, adapterName) {
|
|
@@ -79,6 +123,8 @@ export class SessionTracker {
|
|
|
79
123
|
else if (!opts?.all) {
|
|
80
124
|
filtered = filtered.filter((s) => s.status === "running" || s.status === "idle");
|
|
81
125
|
}
|
|
126
|
+
// Dedup: if a pending-* entry shares a PID with a resolved entry, show only the resolved one
|
|
127
|
+
filtered = deduplicatePendingSessions(filtered);
|
|
82
128
|
return filtered.sort((a, b) => {
|
|
83
129
|
// Running first, then by recency
|
|
84
130
|
if (a.status === "running" && b.status !== "running")
|
|
@@ -102,6 +148,34 @@ export class SessionTracker {
|
|
|
102
148
|
return session;
|
|
103
149
|
}
|
|
104
150
|
}
|
|
151
|
+
/** Check if a process is alive via kill(pid, 0) signal check */
|
|
152
|
+
function defaultIsProcessAlive(pid) {
|
|
153
|
+
try {
|
|
154
|
+
process.kill(pid, 0);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Remove pending-* entries that share a PID with a resolved (non-pending) session.
|
|
163
|
+
* This is a safety net for list output — the poll() reaper handles cleanup in state.
|
|
164
|
+
*/
|
|
165
|
+
function deduplicatePendingSessions(sessions) {
|
|
166
|
+
const realPids = new Set();
|
|
167
|
+
for (const s of sessions) {
|
|
168
|
+
if (!s.id.startsWith("pending-") && s.pid) {
|
|
169
|
+
realPids.add(s.pid);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return sessions.filter((s) => {
|
|
173
|
+
if (s.id.startsWith("pending-") && s.pid && realPids.has(s.pid)) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
105
179
|
function sessionToRecord(session, adapterName) {
|
|
106
180
|
return {
|
|
107
181
|
id: session.id,
|