@cmgmyr/hive 1.5.0 → 1.7.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/claude-plugin/skills/cleanup/SKILL.md +9 -1
- package/claude-plugin/skills/profile/SKILL.md +30 -3
- package/dist/build-info.json +1 -1
- package/dist/cli.js +123 -40
- package/dist/contextCheckpoint.js +2 -2
- package/dist/help.js +16 -4
- package/dist/hook.js +141 -11
- package/dist/leadWatch.js +9 -7
- package/dist/portfolio.js +4 -5
- package/dist/processes.js +37 -15
- package/dist/projectRemove.js +46 -10
- package/dist/scheduler.js +179 -62
- package/dist/spawn.js +70 -15
- package/dist/tmux.js +40 -1
- package/dist/tools/agents.js +147 -39
- package/dist/tools/meta.js +1 -1
- package/dist/tools/pads.js +28 -2
- package/dist/tools/wakes.js +44 -21
- package/package.json +1 -1
package/dist/hook.js
CHANGED
|
@@ -1,7 +1,70 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, parse, relative, sep } from "node:path";
|
|
3
4
|
import { awaitingFirstPromptSql } from "./firstPrompt.js";
|
|
4
5
|
import { liveBackgroundTasks, SUBAGENT_LATCH_SQL, withholdsIdle } from "./backgroundTasks.js";
|
|
6
|
+
function canonicalPathWithMissingSuffix(input) {
|
|
7
|
+
if (!isAbsolute(input) || input.includes("\0"))
|
|
8
|
+
return null;
|
|
9
|
+
let current = parse(input).root;
|
|
10
|
+
const missing = [];
|
|
11
|
+
try {
|
|
12
|
+
current = realpathSync(current);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
for (const part of input.slice(parse(input).root.length).split(sep)) {
|
|
18
|
+
if (part === "" || part === ".")
|
|
19
|
+
continue;
|
|
20
|
+
if (part === "..") {
|
|
21
|
+
if (missing.length)
|
|
22
|
+
missing.pop();
|
|
23
|
+
else
|
|
24
|
+
current = dirname(current);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (missing.length) {
|
|
28
|
+
missing.push(part);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const candidate = join(current, part);
|
|
32
|
+
try {
|
|
33
|
+
current = realpathSync(candidate);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code !== "ENOENT")
|
|
37
|
+
return null;
|
|
38
|
+
missing.push(part);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return join(current, ...missing);
|
|
42
|
+
}
|
|
43
|
+
function acceptsWorkerHook(row, payload, sessionsRoot) {
|
|
44
|
+
if (!row || row.kind !== "agent" || row.codex_home === "" || sessionsRoot === undefined)
|
|
45
|
+
return true;
|
|
46
|
+
const transcript = payload.transcript_path;
|
|
47
|
+
if (typeof transcript === "string" && transcript !== "") {
|
|
48
|
+
const canonicalRoot = canonicalPathWithMissingSuffix(sessionsRoot);
|
|
49
|
+
const canonicalTranscript = canonicalPathWithMissingSuffix(transcript);
|
|
50
|
+
if (canonicalRoot === null || canonicalTranscript === null)
|
|
51
|
+
return false;
|
|
52
|
+
const rel = relative(canonicalRoot, canonicalTranscript);
|
|
53
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
54
|
+
}
|
|
55
|
+
if (transcript !== undefined && transcript !== null && transcript !== "")
|
|
56
|
+
return false;
|
|
57
|
+
return (typeof row.session_id === "string" && row.session_id !== "" &&
|
|
58
|
+
typeof payload.session_id === "string" && payload.session_id !== "" &&
|
|
59
|
+
row.session_id === payload.session_id);
|
|
60
|
+
}
|
|
61
|
+
function validCheckpointThreshold() {
|
|
62
|
+
const text = process.env.HIVE_CONTEXT_CHECKPOINT_PERCENT;
|
|
63
|
+
if (!text || !/^\d+$/.test(text))
|
|
64
|
+
return false;
|
|
65
|
+
const value = Number(text);
|
|
66
|
+
return Number.isInteger(value) && value >= 1 && value <= 100;
|
|
67
|
+
}
|
|
5
68
|
let raw;
|
|
6
69
|
function readRaw() {
|
|
7
70
|
if (raw === undefined) {
|
|
@@ -161,8 +224,33 @@ function stateFor(event, actorId) {
|
|
|
161
224
|
}
|
|
162
225
|
if (process.argv[2] === "post_tool_use") {
|
|
163
226
|
try {
|
|
227
|
+
const kind = process.argv[3];
|
|
228
|
+
const actorId = process.env.HIVE_AGENT_ID ?? "";
|
|
229
|
+
let suppliedPayload;
|
|
230
|
+
if (kind === "codex" && validCheckpointThreshold() && actorId.startsWith("agent:")) {
|
|
231
|
+
suppliedPayload = readPayload();
|
|
232
|
+
const { db } = await import("./db.js");
|
|
233
|
+
const row = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
|
|
234
|
+
.get(actorId);
|
|
235
|
+
if (row?.kind === "agent") {
|
|
236
|
+
const { harnessFor } = await import("./harnesses.js");
|
|
237
|
+
if (harnessFor(row.command).name === "codex") {
|
|
238
|
+
if (row.status !== "running")
|
|
239
|
+
process.exit(0);
|
|
240
|
+
if (typeof row.codex_home === "string" && row.codex_home !== "") {
|
|
241
|
+
const { codexHomeDir } = await import("./codexHome.js");
|
|
242
|
+
if (!acceptsWorkerHook(row, suppliedPayload, join(codexHomeDir(row.codex_home), "sessions"))) {
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
164
249
|
const { runContextCheckpointHook } = await import("./contextCheckpoint.js");
|
|
165
|
-
|
|
250
|
+
if (suppliedPayload === undefined)
|
|
251
|
+
runContextCheckpointHook(kind);
|
|
252
|
+
else
|
|
253
|
+
runContextCheckpointHook(kind, suppliedPayload);
|
|
166
254
|
}
|
|
167
255
|
catch { }
|
|
168
256
|
process.exit(0);
|
|
@@ -172,13 +260,43 @@ try {
|
|
|
172
260
|
const actorId = process.env.HIVE_AGENT_ID;
|
|
173
261
|
if (actorId) {
|
|
174
262
|
const event = process.argv[2] ?? "";
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
263
|
+
const hookPayload = readPayload();
|
|
264
|
+
const row = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
|
|
265
|
+
.get(actorId);
|
|
266
|
+
let guarded = false;
|
|
267
|
+
let state = null;
|
|
268
|
+
if (row?.kind === "agent") {
|
|
269
|
+
const { harnessFor } = await import("./harnesses.js");
|
|
270
|
+
if (harnessFor(row.command).name === "codex") {
|
|
271
|
+
if (row.status !== "running")
|
|
272
|
+
process.exit(0);
|
|
273
|
+
const { codexHomeDir } = await import("./codexHome.js");
|
|
274
|
+
const result = db.transaction(() => {
|
|
275
|
+
const current = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
|
|
276
|
+
.get(actorId);
|
|
277
|
+
if (current?.kind === "agent" && harnessFor(current.command).name === "codex" && current.status !== "running") {
|
|
278
|
+
return { status: "inactive", state: null };
|
|
279
|
+
}
|
|
280
|
+
if (!current || current.kind !== "agent" || typeof current.codex_home !== "string" || !current.codex_home || harnessFor(current.command).name !== "codex") {
|
|
281
|
+
return { status: "changed", state: null };
|
|
282
|
+
}
|
|
283
|
+
const sessionsRoot = join(codexHomeDir(current.codex_home), "sessions");
|
|
284
|
+
if (!acceptsWorkerHook(current, hookPayload, sessionsRoot))
|
|
285
|
+
return { status: "rejected", state: null };
|
|
286
|
+
const state = applyWorkerTransition(actorId, event);
|
|
287
|
+
reconcileSessionId(actorId, hookPayload);
|
|
288
|
+
reconcileTranscriptPath(actorId, hookPayload);
|
|
289
|
+
db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
|
|
290
|
+
return { status: "accepted", state };
|
|
291
|
+
}).immediate();
|
|
292
|
+
if (result.status === "rejected" || result.status === "inactive")
|
|
293
|
+
process.exit(0);
|
|
294
|
+
guarded = result.status === "accepted";
|
|
295
|
+
state = result.state;
|
|
296
|
+
}
|
|
181
297
|
}
|
|
298
|
+
if (!guarded)
|
|
299
|
+
state = applyWorkerTransition(actorId, event);
|
|
182
300
|
// A lead's turn state is its own table; agents.agent_state stays worker-only.
|
|
183
301
|
if (process.env.HIVE_LEAD === "1" && (event === "prompt" || event === "stop" || event === "session_end")) {
|
|
184
302
|
try {
|
|
@@ -195,12 +313,24 @@ try {
|
|
|
195
313
|
catch {
|
|
196
314
|
}
|
|
197
315
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
316
|
+
if (!guarded) {
|
|
317
|
+
reconcileSessionId(actorId, hookPayload);
|
|
318
|
+
reconcileTranscriptPath(actorId, hookPayload);
|
|
319
|
+
db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
|
|
320
|
+
}
|
|
201
321
|
record(actorId, event, state ?? UNCHANGED);
|
|
202
322
|
}
|
|
203
323
|
}
|
|
204
324
|
catch {
|
|
205
325
|
}
|
|
206
326
|
process.exit(0);
|
|
327
|
+
function applyWorkerTransition(actorId, event) {
|
|
328
|
+
const state = stateFor(event, actorId);
|
|
329
|
+
if (state !== null) {
|
|
330
|
+
db.prepare("UPDATE agents SET agent_state = ?, state_changed_at = datetime('now') WHERE actor_id = ? AND kind = 'agent'").run(state, actorId);
|
|
331
|
+
}
|
|
332
|
+
if (event === "prompt") {
|
|
333
|
+
db.prepare(`UPDATE agents SET resumed_at = '' WHERE actor_id = ? AND kind = 'agent' AND ${awaitingFirstPromptSql("agents")}`).run(actorId);
|
|
334
|
+
}
|
|
335
|
+
return state;
|
|
336
|
+
}
|
package/dist/leadWatch.js
CHANGED
|
@@ -2,7 +2,7 @@ import { db } from "./db.js";
|
|
|
2
2
|
import { paneClassifierFor } from "./harnesses.js";
|
|
3
3
|
import { readLeadTurnState } from "./leadState.js";
|
|
4
4
|
import { LEAD_KIND } from "./spawn.js";
|
|
5
|
-
import { holdsHumanInput,
|
|
5
|
+
import { foreignSocket, holdsHumanInput, observationFailed, rowOwnership } from "./tmux.js";
|
|
6
6
|
export const LEAD_TARGET_GONE = "LEAD_TARGET_GONE";
|
|
7
7
|
export const LEAD_TARGET_REISSUED = "LEAD_TARGET_REISSUED";
|
|
8
8
|
export const LEAD_TARGET_RESTARTED = "LEAD_TARGET_RESTARTED";
|
|
@@ -39,14 +39,16 @@ export function evaluateLeadWatch(sub, mode, snapshot, readers) {
|
|
|
39
39
|
return { kind: "invalid", reason: LEAD_TARGET_GONE };
|
|
40
40
|
if (lead.pane_pid !== sub.pane_pid)
|
|
41
41
|
return { kind: "invalid", reason: LEAD_TARGET_RESTARTED };
|
|
42
|
-
if (
|
|
43
|
-
return { kind: "pending", reason: "tmux did not answer" };
|
|
44
|
-
const probe = rowAliveProbe(lead.tmux_socket, lead.tmux_target, snapshot);
|
|
45
|
-
if (probe.live === null)
|
|
42
|
+
if (foreignSocket(lead.tmux_socket))
|
|
46
43
|
return { kind: "pending", reason: "its pane cannot be probed from this process" };
|
|
47
|
-
if (
|
|
44
|
+
if (observationFailed(snapshot))
|
|
45
|
+
return { kind: "pending", reason: "tmux did not answer" };
|
|
46
|
+
const ownership = rowOwnership(lead, snapshot);
|
|
47
|
+
if (ownership === "unknown")
|
|
48
|
+
return { kind: "pending", reason: "pane ownership unknown" };
|
|
49
|
+
if (ownership === "gone")
|
|
48
50
|
return { kind: "invalid", reason: LEAD_TARGET_GONE };
|
|
49
|
-
if (
|
|
51
|
+
if (ownership === "reissued")
|
|
50
52
|
return { kind: "invalid", reason: LEAD_TARGET_REISSUED };
|
|
51
53
|
const state = readLeadTurnState(lead.id);
|
|
52
54
|
if (state === null || state.pane_pid !== sub.pane_pid)
|
package/dist/portfolio.js
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
|
|
|
4
4
|
import { parseTags } from "./result.js";
|
|
5
5
|
import { OPEN_BLOCKERS_SQL } from "./tools/todos.js";
|
|
6
6
|
import { readLeadTurnState } from "./leadState.js";
|
|
7
|
-
import { liveTargets,
|
|
7
|
+
import { liveTargets, ownershipLiveness, rowOwnership } from "./tmux.js";
|
|
8
8
|
export const PORTFOLIO_REASONS = [
|
|
9
9
|
"needs_human",
|
|
10
10
|
"dead_lead_pane",
|
|
@@ -84,15 +84,14 @@ function projectRow(project, now, snapshot) {
|
|
|
84
84
|
const agents = db
|
|
85
85
|
.prepare("SELECT id, kind, agent_state, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
|
|
86
86
|
.all(project.id);
|
|
87
|
-
const liveness = (a) =>
|
|
88
|
-
const reissued = (a) => snapshot !== null && paneReissued(a.pane_pid, rowAliveProbe(a.tmux_socket, a.tmux_target, snapshot));
|
|
87
|
+
const liveness = (a) => ownershipLiveness(rowOwnership(a, snapshot));
|
|
89
88
|
const workers = { working: 0, idle: 0, needs_input: 0, other: 0, unreachable: 0, unconfirmed: 0 };
|
|
90
89
|
let liveWorking = 0;
|
|
91
90
|
let liveWaiting = 0;
|
|
92
91
|
let lead = { state: "none", agent_id: null, turn: "unknown" };
|
|
93
92
|
for (const a of agents) {
|
|
94
93
|
if (a.kind === "lead") {
|
|
95
|
-
const live =
|
|
94
|
+
const live = liveness(a);
|
|
96
95
|
lead = {
|
|
97
96
|
state: live === true ? "alive" : live === false ? "dead_pane" : "unknown",
|
|
98
97
|
agent_id: a.id,
|
|
@@ -102,7 +101,7 @@ function projectRow(project, now, snapshot) {
|
|
|
102
101
|
}
|
|
103
102
|
if (a.kind !== "agent")
|
|
104
103
|
continue;
|
|
105
|
-
const live =
|
|
104
|
+
const live = liveness(a);
|
|
106
105
|
if (live === false)
|
|
107
106
|
workers.unreachable++;
|
|
108
107
|
else if (live === null)
|
package/dist/processes.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { getProject } from "./context.js";
|
|
2
2
|
import { db } from "./db.js";
|
|
3
3
|
import { loadProjectYml } from "./projectYml.js";
|
|
4
|
-
import { closeAgentRow, killAgentPane, relayoutAfterPaneLeft } from "./spawn.js";
|
|
5
|
-
import { cancelCopyMode,
|
|
4
|
+
import { closeAgentRow, currentOwnership, killAgentPane, paneIdentity, relayoutAfterPaneLeft } from "./spawn.js";
|
|
5
|
+
import { cancelCopyMode, observeRowOwnership, paneProcessExited, paneVisibility, paneWindow, projectWindows, rowLive, sessionName, tmux, } from "./tmux.js";
|
|
6
6
|
// One derivation for every surface that reports where a project's processes are: `hive status`,
|
|
7
7
|
// `hive doctor`, and the dashboard. Two tmux window lookups per project, then one per running
|
|
8
8
|
// process, and none at all for a project with neither a running command nor a defined one.
|
|
@@ -96,6 +96,19 @@ function waitForPaneToExit(target, timeoutMs) {
|
|
|
96
96
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(STOP_POLL_MS, remaining));
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
const UNKNOWN_OWNERSHIP_NOTE = "its pane ownership is unknown (no pane pid recorded, a legacy window target, or another tmux socket), so hive left it running";
|
|
100
|
+
const REISSUED_NOTE = "its pane id now belongs to another process, which was left alone";
|
|
101
|
+
const CHANGED_NOTE = "its row was closed or recorded a different pane during the stop, so hive touched nothing further";
|
|
102
|
+
// Unknown, or a row that changed underneath the stop, is left running: closing it would hide a
|
|
103
|
+
// process that may still be running from every surface that reports one.
|
|
104
|
+
function notOwned(row, ownership) {
|
|
105
|
+
if (ownership === "unknown")
|
|
106
|
+
return { name: row.name, leg: "unreachable", note: UNKNOWN_OWNERSHIP_NOTE };
|
|
107
|
+
if (ownership === "changed")
|
|
108
|
+
return { name: row.name, leg: "unreachable", note: CHANGED_NOTE };
|
|
109
|
+
closeAgentRow(row.id, paneIdentity(row));
|
|
110
|
+
return { name: row.name, leg: "already-gone", ...(ownership === "reissued" ? { note: REISSUED_NOTE } : {}) };
|
|
111
|
+
}
|
|
99
112
|
// The only thing that stops a hive.yml command. kill-pane alone is SIGHUP, which a dev server with
|
|
100
113
|
// detached grandchildren survives while still holding its port, so the graceful leg comes first and
|
|
101
114
|
// the receipt says which one ended it.
|
|
@@ -103,19 +116,19 @@ function waitForPaneToExit(target, timeoutMs) {
|
|
|
103
116
|
// argument so every caller has to declare its trigger from the closed set docs/projects.md is
|
|
104
117
|
// checked against, and whichever caller prints it already knows which one it passed.
|
|
105
118
|
export function stopProcess(row, reason) {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (probe.live === null)
|
|
119
|
+
const identity = paneIdentity(row);
|
|
120
|
+
const ownership = observeRowOwnership(identity);
|
|
121
|
+
if (ownership === "probe-failed")
|
|
110
122
|
return { name: row.name, leg: "unreachable" };
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (probe.live === false || paneReissued(row.pane_pid, probe)) {
|
|
114
|
-
closeAgentRow(row.id);
|
|
115
|
-
return { name: row.name, leg: "already-gone" };
|
|
116
|
-
}
|
|
123
|
+
if (ownership !== "live")
|
|
124
|
+
return notOwned(row, ownership);
|
|
117
125
|
markStopping(row.project_id, row.id);
|
|
118
126
|
const window = paneWindow(row.tmux_target);
|
|
127
|
+
const beforeSignal = currentOwnership(row.id, identity);
|
|
128
|
+
if (beforeSignal !== "live") {
|
|
129
|
+
clearStoppingMarker(row.project_id, row.id);
|
|
130
|
+
return notOwned(row, beforeSignal);
|
|
131
|
+
}
|
|
119
132
|
try {
|
|
120
133
|
// In copy mode C-c is `cancel`, not SIGINT, so the graceful leg would silently do nothing to a
|
|
121
134
|
// pane someone scrolled back in. A stop may take that scrollback position: the process is ending.
|
|
@@ -126,6 +139,11 @@ export function stopProcess(row, reason) {
|
|
|
126
139
|
}
|
|
127
140
|
let leg = "interrupted";
|
|
128
141
|
if (!waitForPaneToExit(row.tmux_target, STOP_GRACE_MS)) {
|
|
142
|
+
const beforeKill = currentOwnership(row.id, identity);
|
|
143
|
+
if (beforeKill !== "live") {
|
|
144
|
+
clearStoppingMarker(row.project_id, row.id);
|
|
145
|
+
return notOwned(row, beforeKill);
|
|
146
|
+
}
|
|
129
147
|
try {
|
|
130
148
|
killAgentPane(row.tmux_target);
|
|
131
149
|
}
|
|
@@ -137,7 +155,7 @@ export function stopProcess(row, reason) {
|
|
|
137
155
|
// RUNNING, so the process stays visible to hive status, hive stop and the janitor.
|
|
138
156
|
if (leg === "still-running")
|
|
139
157
|
return { name: row.name, leg };
|
|
140
|
-
closeAgentRow(row.id,
|
|
158
|
+
closeAgentRow(row.id, identity);
|
|
141
159
|
clearStoppingMarker(row.project_id, row.id);
|
|
142
160
|
relayoutAfterPaneLeft(window);
|
|
143
161
|
return { name: row.name, leg };
|
|
@@ -152,9 +170,13 @@ export function stopLine(stopped) {
|
|
|
152
170
|
case "killed":
|
|
153
171
|
return `${stopped.name}: stopped (killed after ${STOP_GRACE_MS / 1000}s)`;
|
|
154
172
|
case "already-gone":
|
|
155
|
-
return
|
|
173
|
+
return stopped.note
|
|
174
|
+
? `${stopped.name}: already gone (${stopped.note})`
|
|
175
|
+
: `${stopped.name}: already gone (nothing was running)`;
|
|
156
176
|
case "unreachable":
|
|
157
|
-
return
|
|
177
|
+
return stopped.note
|
|
178
|
+
? `${stopped.name}: ${stopped.note}`
|
|
179
|
+
: `${stopped.name}: tmux could not be probed, so hive left it running`;
|
|
158
180
|
case "still-running":
|
|
159
181
|
return `${stopped.name}: still running: its pane survived C-c and kill-pane, so hive left the row open`;
|
|
160
182
|
}
|
package/dist/projectRemove.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { backupNow } from "./backup.js";
|
|
2
2
|
import { dataDir, db } from "./db.js";
|
|
3
|
+
import { LEAD_KIND } from "./spawn.js";
|
|
4
|
+
import { foreignSocket, liveTargets, observationFailed, rowOwnership } from "./tmux.js";
|
|
3
5
|
export const PROJECT_OWNER_TABLES = [
|
|
4
6
|
"pads",
|
|
5
7
|
"todos",
|
|
@@ -28,17 +30,49 @@ export function formatRowCounts(counts) {
|
|
|
28
30
|
.map(([table, n]) => `${table}: ${n}`)
|
|
29
31
|
.join(", ");
|
|
30
32
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const runningRows = (projectId) => db
|
|
34
|
+
.prepare("SELECT id, name, kind, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
|
|
35
|
+
.all(projectId);
|
|
36
|
+
const identities = (rows) => JSON.stringify(rows.map((r) => [r.id, r.tmux_target, r.tmux_socket, r.pane_pid]));
|
|
37
|
+
function assertNoRunningAgents(projectId, name, running, panes) {
|
|
35
38
|
if (running.length === 0)
|
|
36
39
|
return;
|
|
37
|
-
const
|
|
38
|
-
|
|
40
|
+
const snapshot = panes === undefined ? liveTargets() : panes;
|
|
41
|
+
const blockers = running
|
|
42
|
+
.map((a) => ({ ...a, ownership: rowOwnership(a, snapshot) }))
|
|
43
|
+
.filter((a) => a.ownership === "live" || a.ownership === "unknown");
|
|
44
|
+
if (blockers.length === 0)
|
|
45
|
+
return;
|
|
46
|
+
const names = blockers
|
|
47
|
+
.map((a) => `${a.name} (agent ${a.id}, ${a.ownership === "live" ? "owns a live pane" : "pane identity unknown"})`)
|
|
48
|
+
.join(", ");
|
|
49
|
+
const hints = [];
|
|
50
|
+
const liveLeads = blockers.filter((a) => a.ownership === "live" && a.kind === LEAD_KIND);
|
|
51
|
+
const liveOthers = blockers.filter((a) => a.ownership === "live" && a.kind !== LEAD_KIND);
|
|
52
|
+
const unknown = blockers.filter((a) => a.ownership === "unknown");
|
|
53
|
+
if (liveLeads.length > 0)
|
|
54
|
+
hints.push("A live lead is stopped or restarted from its own terminal, never retired with row_only.");
|
|
55
|
+
if (liveOthers.length > 0)
|
|
56
|
+
hints.push("Stop a live worker or command with agent_close first.");
|
|
57
|
+
if (unknown.length > 0 && observationFailed(snapshot)) {
|
|
58
|
+
hints.push("tmux could not be probed, so no row's ownership could be checked. Retry in a few seconds.");
|
|
59
|
+
}
|
|
60
|
+
else if (unknown.length > 0) {
|
|
61
|
+
const foreign = unknown.some((a) => foreignSocket(a.tmux_socket));
|
|
62
|
+
hints.push("A row whose pane cannot be verified is retired explicitly by a human or peer lead through this project's MCP " +
|
|
63
|
+
`tool, ${unknown.map((a) => `agent_close({agent_id: ${a.id}, row_only: true})`).join(", ")}` +
|
|
64
|
+
(foreign
|
|
65
|
+
? ", or run this again from the tmux socket a foreign-socket row was recorded on, where its ownership can be verified."
|
|
66
|
+
: "."));
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`project ${projectId} ("${name}") has running agents: ${names}. Nothing deleted. ${hints.join(" ")}`);
|
|
39
69
|
}
|
|
40
|
-
const deleteProjectRows = db.transaction((projectId, name, expected, after) => {
|
|
41
|
-
|
|
70
|
+
const deleteProjectRows = db.transaction((projectId, name, expected, observed, after) => {
|
|
71
|
+
const running = runningRows(projectId);
|
|
72
|
+
if (identities(running) !== observed.rows) {
|
|
73
|
+
throw new Error(`project ${projectId} ("${name}") changed while the snapshot was taken; nothing removed, run it again.`);
|
|
74
|
+
}
|
|
75
|
+
assertNoRunningAgents(projectId, name, running, observed.panes);
|
|
42
76
|
if (JSON.stringify(projectRowCounts(projectId)) !== JSON.stringify(expected)) {
|
|
43
77
|
throw new Error(`project ${projectId} ("${name}") changed while the snapshot was taken; nothing removed, run it again.`);
|
|
44
78
|
}
|
|
@@ -52,7 +86,7 @@ export function removeProject(projectId, opts) {
|
|
|
52
86
|
const target = db.prepare("SELECT id, name, path FROM projects WHERE id = ?").get(projectId);
|
|
53
87
|
if (!target)
|
|
54
88
|
throw new Error(`no project ${projectId}.`);
|
|
55
|
-
assertNoRunningAgents(projectId, target.name);
|
|
89
|
+
assertNoRunningAgents(projectId, target.name, runningRows(projectId));
|
|
56
90
|
const counts = projectRowCounts(projectId);
|
|
57
91
|
let snapshot = null;
|
|
58
92
|
if (opts.snapshot) {
|
|
@@ -62,7 +96,9 @@ export function removeProject(projectId, opts) {
|
|
|
62
96
|
snapshot = result.path ?? null;
|
|
63
97
|
}
|
|
64
98
|
opts.afterSnapshot?.();
|
|
99
|
+
const rows = runningRows(projectId);
|
|
100
|
+
const observed = { rows: identities(rows), panes: rows.length > 0 ? liveTargets() : null };
|
|
65
101
|
const removed = { deleted: { id: target.id, name: target.name, path: target.path }, counts, snapshot };
|
|
66
|
-
deleteProjectRows.immediate(projectId, target.name, counts, () => opts.onRemoved?.(removed));
|
|
102
|
+
deleteProjectRows.immediate(projectId, target.name, counts, observed, () => opts.onRemoved?.(removed));
|
|
67
103
|
return removed;
|
|
68
104
|
}
|