@cmgmyr/hive 1.10.0 → 1.11.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/crew/model.mjs +3 -0
- package/dist/build-info.json +1 -1
- package/dist/cli.js +125 -46
- package/dist/context.js +7 -1
- package/dist/crew.js +20 -2
- package/dist/db.js +139 -0
- package/dist/help.js +27 -0
- package/dist/hook.js +32 -2
- package/dist/index.js +2 -0
- package/dist/kickoff.js +29 -6
- package/dist/leadHandoff.js +525 -0
- package/dist/leadHandoffCli.js +295 -0
- package/dist/leadHandoffState.js +96 -0
- package/dist/leadLaunch.js +54 -0
- package/dist/leadMessageDelivery.js +44 -7
- package/dist/leadState.js +98 -17
- package/dist/portfolio.js +2 -0
- package/dist/projectRemove.js +1 -0
- package/dist/projectYml.js +8 -1
- package/dist/questions.js +254 -0
- package/dist/scheduler.js +25 -5
- package/dist/spawn.js +44 -15
- package/dist/staleServers.js +122 -0
- package/dist/tmux.js +9 -6
- package/dist/tools/agents.js +35 -35
- package/dist/tools/meta.js +4 -1
- package/dist/tools/questions.js +87 -0
- package/dist/tools/wakes.js +9 -9
- package/package.json +1 -1
package/dist/hook.js
CHANGED
|
@@ -177,6 +177,13 @@ async function stopProcessesForEndedLead(actorId, payload) {
|
|
|
177
177
|
.get(actorId);
|
|
178
178
|
if (!row)
|
|
179
179
|
return;
|
|
180
|
+
// A deliberate handoff's predecessor ending is not the lead ending; its successor keeps the crew.
|
|
181
|
+
const lead = db.prepare("SELECT id FROM agents WHERE actor_id = ? AND kind = 'lead' AND status = 'running'").get(actorId);
|
|
182
|
+
if (lead !== undefined) {
|
|
183
|
+
const { predecessorFence } = await import("./leadState.js");
|
|
184
|
+
if (predecessorFence(lead.id, payload.session_id))
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
180
187
|
// Imported here and nowhere above: harnesses.ts itself pulls tmux at module scope, and every
|
|
181
188
|
// other event in this file runs on every turn of every session without needing any of it.
|
|
182
189
|
const { harnessFor } = await import("./harnesses.js");
|
|
@@ -313,7 +320,8 @@ try {
|
|
|
313
320
|
if (!guarded)
|
|
314
321
|
state = applyWorkerTransition(actorId, event);
|
|
315
322
|
// A lead's turn state is its own table; agents.agent_state stays worker-only.
|
|
316
|
-
if (process.env.HIVE_LEAD === "1" &&
|
|
323
|
+
if (process.env.HIVE_LEAD === "1" &&
|
|
324
|
+
["prompt", "stop", "session_end"].includes(event)) {
|
|
317
325
|
try {
|
|
318
326
|
const { applyLeadHook } = await import("./leadState.js");
|
|
319
327
|
applyLeadHook(actorId, event, readPayload(), () => waitingOnSubagents(actorId, readPayload()));
|
|
@@ -321,6 +329,18 @@ try {
|
|
|
321
329
|
catch {
|
|
322
330
|
}
|
|
323
331
|
}
|
|
332
|
+
if (event === "stop" && process.env.HIVE_LEAD === "1") {
|
|
333
|
+
try {
|
|
334
|
+
const started = db
|
|
335
|
+
.prepare(`SELECT 1 AS hit FROM lead_handoffs h JOIN agents a ON a.id = h.lead_agent_id
|
|
336
|
+
WHERE a.actor_id = ? AND a.kind = 'lead' AND h.state = 'started'`)
|
|
337
|
+
.get(actorId);
|
|
338
|
+
if (started !== undefined)
|
|
339
|
+
(await import("./leadHandoff.js")).completeHandoffOnStop(actorId);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
}
|
|
343
|
+
}
|
|
324
344
|
if (event === "session_start" && process.env.HIVE_LEAD === "1") {
|
|
325
345
|
try {
|
|
326
346
|
await registerLeadMessagingSocket(actorId);
|
|
@@ -340,7 +360,17 @@ try {
|
|
|
340
360
|
reconcileTranscriptPath(actorId, hookPayload);
|
|
341
361
|
db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
|
|
342
362
|
}
|
|
343
|
-
|
|
363
|
+
let logState = state ?? UNCHANGED;
|
|
364
|
+
if (event === "prompt" && process.env.HIVE_LEAD === "1") {
|
|
365
|
+
try {
|
|
366
|
+
const { isHiveFirstMessage, FIRST_MESSAGE_LOG_STATE } = await import("./leadState.js");
|
|
367
|
+
if (isHiveFirstMessage(readPayload()))
|
|
368
|
+
logState = FIRST_MESSAGE_LOG_STATE;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
record(actorId, event, logState);
|
|
344
374
|
}
|
|
345
375
|
}
|
|
346
376
|
catch {
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { registerTodos } from "./tools/todos.js";
|
|
|
10
10
|
import { registerKv } from "./tools/kv.js";
|
|
11
11
|
import { registerLeases } from "./tools/leases.js";
|
|
12
12
|
import { registerWakes } from "./tools/wakes.js";
|
|
13
|
+
import { registerQuestions } from "./tools/questions.js";
|
|
13
14
|
import { registerQueenAudit } from "./tools/queenAudit.js";
|
|
14
15
|
import { registerPrompts } from "./prompts.js";
|
|
15
16
|
import { schedulerIntervalMs, startScheduler } from "./scheduler.js";
|
|
@@ -34,6 +35,7 @@ registerTodos(server);
|
|
|
34
35
|
registerKv(server);
|
|
35
36
|
registerLeases(server);
|
|
36
37
|
registerWakes(server);
|
|
38
|
+
registerQuestions(server);
|
|
37
39
|
registerQueenAudit(server);
|
|
38
40
|
registerPrompts(server);
|
|
39
41
|
startScheduler(schedulerIntervalMs());
|
package/dist/kickoff.js
CHANGED
|
@@ -97,6 +97,20 @@ async function digest(projectPath, profile, warnings) {
|
|
|
97
97
|
lines.push("", "Standing process for this project: run `hive runbook`.");
|
|
98
98
|
return truncate(lines.join("\n"), CONTEXT_BUDGET);
|
|
99
99
|
}
|
|
100
|
+
// Lead-only: a worker returned above, and a session hive did not start as a lead gets nothing.
|
|
101
|
+
async function leadHandoffText() {
|
|
102
|
+
const actor = process.env.HIVE_AGENT_ID;
|
|
103
|
+
if (process.env.HIVE_LEAD !== "1" || !actor)
|
|
104
|
+
return null;
|
|
105
|
+
try {
|
|
106
|
+
const { migrate } = await import("./db.js");
|
|
107
|
+
migrate();
|
|
108
|
+
return (await import("./leadHandoff.js")).handoffInjection(actor);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
100
114
|
// Shared with `hive lead`, which passes the first message on the command line only when this holds.
|
|
101
115
|
export async function kickoffGate(dir) {
|
|
102
116
|
const { activeProfile, DEFAULT_LEAD_BRANCHES, loadProjectYml } = await import("./projectYml.js");
|
|
@@ -128,13 +142,20 @@ export async function evaluate(cwd, opts = {}) {
|
|
|
128
142
|
catch {
|
|
129
143
|
return { fired: false, reason: "cwd does not exist" };
|
|
130
144
|
}
|
|
145
|
+
const handoff = await leadHandoffText();
|
|
146
|
+
const handoffOnly = (warnings) => ({
|
|
147
|
+
fired: true,
|
|
148
|
+
payload: JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: handoff } }),
|
|
149
|
+
warnings,
|
|
150
|
+
});
|
|
131
151
|
const gate = await kickoffGate(dir);
|
|
132
152
|
if (!gate.ok)
|
|
133
|
-
return { fired: false, reason: gate.reason, warnings: gate.warnings };
|
|
153
|
+
return handoff === null ? { fired: false, reason: gate.reason, warnings: gate.warnings } : handoffOnly(gate.warnings);
|
|
134
154
|
const { config, warnings, sources, profile } = gate;
|
|
135
155
|
const context = await digest(dir, profile, warnings);
|
|
136
|
-
if (context == null)
|
|
137
|
-
return { fired: false, reason: "not a registered hive project root", warnings };
|
|
156
|
+
if (context == null) {
|
|
157
|
+
return handoff === null ? { fired: false, reason: "not a registered hive project root", warnings } : handoffOnly(warnings);
|
|
158
|
+
}
|
|
138
159
|
// Codex's SessionStartHookSpecificOutputWire is additionalProperties:false and permits only
|
|
139
160
|
// hookEventName/additionalContext - initialUserMessage inside it fails the whole payload
|
|
140
161
|
// ("SessionStart Failed"), silently dropping the board too. Measured, todo 567 comment 1864.
|
|
@@ -145,13 +166,15 @@ export async function evaluate(cwd, opts = {}) {
|
|
|
145
166
|
const build = (text) => JSON.stringify({
|
|
146
167
|
hookSpecificOutput: {
|
|
147
168
|
hookEventName: "SessionStart",
|
|
148
|
-
additionalContext: text
|
|
169
|
+
additionalContext: handoff === null ? text : `${text}\n\n${handoff}`,
|
|
149
170
|
...(hookMessage === null ? {} : { initialUserMessage: hookMessage }),
|
|
150
171
|
},
|
|
151
172
|
});
|
|
152
173
|
let payload = build(context);
|
|
153
|
-
|
|
154
|
-
|
|
174
|
+
// The handoff rides outside the digest budget: only the digest is ever cut to fit.
|
|
175
|
+
const budget = OUTPUT_BUDGET + (handoff === null ? 0 : JSON.stringify(handoff).length);
|
|
176
|
+
if (payload.length > budget) {
|
|
177
|
+
const overflow = payload.length - budget;
|
|
155
178
|
payload = build(truncate(context, Math.max(0, context.length - overflow - 32)));
|
|
156
179
|
}
|
|
157
180
|
return { fired: true, payload, warnings, firstMessage: first };
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { harnessFor, paneClassifierFor } from "./harnesses.js";
|
|
4
|
+
import { casHandoff, HELD_REASON_HANDOFF, handoffHoldsAutomation, nowSql, olderThan, PASS_TIMEOUT_SECONDS, PRE_RESPAWN_STATES, passPolicy, quotedPolicy, readActiveHandoff, readHandoff, STOP_HOLD_CAP_SECONDS, SUCCESSOR_NOT_READY, } from "./leadHandoffState.js";
|
|
5
|
+
import { holdsHumanInput, paneInCopyMode, rowOwnership, tmuxSocketPath } from "./tmux.js";
|
|
6
|
+
import { readLeadSafetySnapshot } from "./leadState.js";
|
|
7
|
+
import { readTurnCount } from "./turnCount.js";
|
|
8
|
+
// Tests read these through this module; the leaf owns them.
|
|
9
|
+
export { casHandoff, HELD_REASON_HANDOFF, handoffHoldsWake, readActiveHandoff, readHandoff, readHandoffGate, SUCCESSOR_NOT_READY, } from "./leadHandoffState.js";
|
|
10
|
+
export const HANDOFF_PAD_NAME = "hive-lead-handoff";
|
|
11
|
+
export const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
12
|
+
export function handoffEligibility(lead, budget, snapshot = readLeadSafetySnapshot(lead.id)) {
|
|
13
|
+
if (budget === null || budget.auto_handoff !== true)
|
|
14
|
+
return { eligible: false, why: "auto_handoff is not enabled" };
|
|
15
|
+
if (harnessFor(lead.command).name !== "claude")
|
|
16
|
+
return { eligible: false, why: "the lead is not a Claude lead" };
|
|
17
|
+
if (lead.pane_pid === "")
|
|
18
|
+
return { eligible: false, why: "the lead's pane pid is unknown" };
|
|
19
|
+
if (snapshot === null ||
|
|
20
|
+
snapshot.session_id === "" ||
|
|
21
|
+
snapshot.pane_pid !== lead.pane_pid ||
|
|
22
|
+
snapshot.snapshot_pane_pid !== lead.pane_pid ||
|
|
23
|
+
snapshot.snapshot_session_id !== snapshot.session_id) {
|
|
24
|
+
return { eligible: false, why: "the lead's current epoch is unknown" };
|
|
25
|
+
}
|
|
26
|
+
const turns = readTurnCount(snapshot.transcript_path);
|
|
27
|
+
if (turns === null)
|
|
28
|
+
return { eligible: false, why: "the lead's turn count is unavailable" };
|
|
29
|
+
if (turns < budget.warn)
|
|
30
|
+
return { eligible: false, why: "below the warn budget" };
|
|
31
|
+
return {
|
|
32
|
+
eligible: true,
|
|
33
|
+
reason: turns >= budget.stop ? "stop" : "warn",
|
|
34
|
+
turns,
|
|
35
|
+
epoch: { pane_pid: lead.pane_pid, session_id: snapshot.session_id },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Respawn safety, not idleness: any live background task of any type (live subagents included), a
|
|
39
|
+
// Stop without background evidence, or a prompt newer than the last Stop vetoes. No expiry.
|
|
40
|
+
export function backgroundVeto(snapshot) {
|
|
41
|
+
if (snapshot === null || snapshot.snapshot_session_id !== snapshot.session_id || snapshot.session_id === "") {
|
|
42
|
+
return "no background evidence for the current session";
|
|
43
|
+
}
|
|
44
|
+
if (snapshot.stop_prompt_seq === null)
|
|
45
|
+
return "no completed turn yet in this session";
|
|
46
|
+
if (snapshot.stop_prompt_seq !== snapshot.prompt_seq)
|
|
47
|
+
return "a turn started after the last completed one";
|
|
48
|
+
if (snapshot.stop_background === null)
|
|
49
|
+
return "the last completed turn reported no background-task evidence";
|
|
50
|
+
let live;
|
|
51
|
+
try {
|
|
52
|
+
live = JSON.parse(snapshot.stop_background);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return "the background-task evidence is unreadable";
|
|
56
|
+
}
|
|
57
|
+
if (live.length > 0) {
|
|
58
|
+
const types = [...new Set(live.map((t) => t.type || "unknown"))].sort().join(", ");
|
|
59
|
+
return `${live.length} background task${live.length === 1 ? "" : "s"} still live (${types})`;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
// One request and one request wake per epoch. The wake carries no due_at: the scheduler sets it
|
|
64
|
+
// when the pass's quiet rule allows, so nothing ordinary can deliver it early.
|
|
65
|
+
const armTransaction = db.transaction((input) => {
|
|
66
|
+
const inserted = db
|
|
67
|
+
.prepare(`INSERT INTO lead_handoffs (project_id, lead_agent_id, pane_target, tmux_socket, predecessor_pane_pid,
|
|
68
|
+
predecessor_session_id, reason, state, pass, pass_started_at, hold_since)
|
|
69
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, strftime('%Y-%m-%d %H:%M:%f', 'now'),
|
|
70
|
+
CASE WHEN ? = 'stop' THEN strftime('%Y-%m-%d %H:%M:%f', 'now') END)
|
|
71
|
+
ON CONFLICT DO NOTHING
|
|
72
|
+
RETURNING id`)
|
|
73
|
+
.get(input.lead.project_id, input.lead.id, input.lead.tmux_target, input.lead.tmux_socket, input.epoch.pane_pid, input.epoch.session_id, input.reason, input.reason === "stop" ? "wind_down" : "pending", input.reason);
|
|
74
|
+
if (inserted === undefined) {
|
|
75
|
+
const existing = db
|
|
76
|
+
.prepare(`SELECT id FROM lead_handoffs WHERE project_id = ? AND lead_agent_id = ?
|
|
77
|
+
AND predecessor_pane_pid = ? AND predecessor_session_id = ?`)
|
|
78
|
+
.get(input.lead.project_id, input.lead.id, input.epoch.pane_pid, input.epoch.session_id);
|
|
79
|
+
const active = existing ?? readActiveHandoff(input.lead.id) ?? undefined;
|
|
80
|
+
return { requestId: active?.id ?? 0, created: false };
|
|
81
|
+
}
|
|
82
|
+
const wake = db
|
|
83
|
+
.prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at)
|
|
84
|
+
SELECT ?, ?, ?, 'delay', actor_id, tmux_target, NULL FROM agents WHERE id = ?
|
|
85
|
+
RETURNING id`)
|
|
86
|
+
.get(input.lead.project_id, input.ownerActor, input.requestBody(inserted.id), input.lead.id);
|
|
87
|
+
db.prepare("UPDATE lead_handoffs SET request_wake_id = ? WHERE id = ?").run(wake.id, inserted.id);
|
|
88
|
+
return { requestId: inserted.id, created: true };
|
|
89
|
+
});
|
|
90
|
+
export function armHandoffRequest(input) {
|
|
91
|
+
return armTransaction.immediate(input);
|
|
92
|
+
}
|
|
93
|
+
const failTransaction = db.transaction((id, failure, from, where) => {
|
|
94
|
+
if (!casHandoff(id, from, { state: "failed", failure, owner_token: null, owner_pid: null }, where))
|
|
95
|
+
return false;
|
|
96
|
+
db.prepare(`UPDATE wakes SET cancelled_at = datetime('now'), held_reason = ?
|
|
97
|
+
WHERE id = (SELECT request_wake_id FROM lead_handoffs WHERE id = ?) AND fired_at IS NULL AND cancelled_at IS NULL`).run(`lead handoff #${id} failed: ${failure}`, id);
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
export function failHandoff(id, failure, from = PRE_RESPAWN_STATES, where = {}) {
|
|
101
|
+
return failTransaction.immediate(id, failure, from, where);
|
|
102
|
+
}
|
|
103
|
+
function processAlive(pid) {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
return e.code === "EPERM";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// After the destructive claim nothing is retried: a successor the row no longer names fails the row,
|
|
113
|
+
// and a respawn that never settled releases its holds after the cap.
|
|
114
|
+
function settleAfterRespawn(row, lead) {
|
|
115
|
+
if (row.state === "started" && row.successor_pane_pid !== null && lead.pane_pid !== row.successor_pane_pid) {
|
|
116
|
+
casHandoff(row.id, ["started"], { state: "failed", failure: "the lead pane no longer runs the successor this handoff started" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (row.state === "started" && row.blocked_reason !== SUCCESSOR_NOT_READY && olderThan(row.started_at, STOP_HOLD_CAP_SECONDS)) {
|
|
120
|
+
casHandoff(row.id, ["started"], { blocked_reason: SUCCESSOR_NOT_READY });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if ((row.state === "respawning" || row.state === "ambiguous") && row.hold_released_at === null && olderThan(row.updated_at, STOP_HOLD_CAP_SECONDS)) {
|
|
124
|
+
casHandoff(row.id, [row.state], { hold_released_at: nowSql(), blocked_reason: row.failure ?? "the respawn never settled" });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export const REQUEST_BODY_PREFIX = "hive lead handoff request #";
|
|
128
|
+
export const HANDOFF_HEADINGS = [
|
|
129
|
+
"IN FLIGHT",
|
|
130
|
+
"WAITING ON",
|
|
131
|
+
"QUEUED NEXT",
|
|
132
|
+
"OPEN QUESTIONS FOR THE HUMAN",
|
|
133
|
+
"HUMAN INSTRUCTIONS THIS SESSION",
|
|
134
|
+
"HUMAN'S LAST REQUEST",
|
|
135
|
+
"VERIFY ON ARRIVAL",
|
|
136
|
+
"DON'T REDO",
|
|
137
|
+
"SESSION WORK TO RESTORE",
|
|
138
|
+
];
|
|
139
|
+
export function requestBody(row, turns) {
|
|
140
|
+
const policy = passPolicy(row.pass, row.reason);
|
|
141
|
+
return [
|
|
142
|
+
`${REQUEST_BODY_PREFIX}${row.id} (pass ${row.pass}, ${row.reason} budget` +
|
|
143
|
+
`${turns === null ? "" : `, ${turns} turns`}). hive will replace this session with a fresh one in the same pane.`,
|
|
144
|
+
"",
|
|
145
|
+
"1. Finish or park any write you have in progress, so the project's own records are current.",
|
|
146
|
+
`2. Write the pad "${HANDOFF_PAD_NAME}" with exactly these headings, in this order. "none" is a valid entry; ` +
|
|
147
|
+
"point into the store rather than copying it, and keep it under about 8,000 characters:",
|
|
148
|
+
...HANDOFF_HEADINGS.map((h) => ` ${h}`),
|
|
149
|
+
" Under SESSION WORK TO RESTORE list every /loop, cron, ScheduleWakeup, monitor or in-session task list " +
|
|
150
|
+
"and how to re-arm it; none of them survive the new session. Re-arm what you can as hive wakes now.",
|
|
151
|
+
"3. Read the pad back and note its id and revision.",
|
|
152
|
+
`4. Tell the human in plain words: "I will hand off after ${policy.graceSeconds} quiet seconds. Human input postpones it."`,
|
|
153
|
+
`5. Run: hive lead-handoff --request ${row.id} --pad <pad id> --revision <revision>`,
|
|
154
|
+
"",
|
|
155
|
+
"If you cannot write the pad, say so and do not run the command; this session stays as it is.",
|
|
156
|
+
].join("\n");
|
|
157
|
+
}
|
|
158
|
+
// Automated deliveries to the lead other than the handoff's own request wakes.
|
|
159
|
+
export function automationBlocker(lead, seconds) {
|
|
160
|
+
const inFlight = db
|
|
161
|
+
.prepare(`SELECT 1 AS hit FROM agent_messages WHERE to_agent_id = ? AND delivery_status IN ('socket-pending', 'fallback-claimed')
|
|
162
|
+
AND created_at >= datetime('now', '-7 days')
|
|
163
|
+
UNION ALL
|
|
164
|
+
SELECT 1 FROM wakes WHERE deliver_actor = ? AND body NOT LIKE ? AND fired_at IS NOT NULL AND cancelled_at IS NULL
|
|
165
|
+
AND delivery_method LIKE 'socket%' AND confirmed_at IS NULL AND socket_attempt_at >= datetime('now', '-7 days')
|
|
166
|
+
LIMIT 1`)
|
|
167
|
+
.get(lead.id, lead.actor_id, `${REQUEST_BODY_PREFIX}%`);
|
|
168
|
+
if (inFlight !== undefined)
|
|
169
|
+
return "a delivery to the lead is still in flight";
|
|
170
|
+
if (seconds === null)
|
|
171
|
+
return null;
|
|
172
|
+
const window = `-${seconds} seconds`;
|
|
173
|
+
const recent = db
|
|
174
|
+
.prepare(`SELECT 1 AS hit FROM wakes WHERE deliver_actor = ? AND body NOT LIKE ?
|
|
175
|
+
AND (fired_at >= datetime('now', ?) OR typed_at >= datetime('now', ?) OR socket_attempt_at >= datetime('now', ?))
|
|
176
|
+
AND NOT (kind IN ('idle_any', 'idle_all') AND watch_scope IS NULL)
|
|
177
|
+
AND NOT EXISTS (SELECT 1 FROM wake_idle_notices n JOIN wakes p ON p.id = wakes.parent_wake_id
|
|
178
|
+
WHERE n.notice_wake_id = wakes.id AND p.watch_scope IS NOT NULL)
|
|
179
|
+
UNION ALL
|
|
180
|
+
SELECT 1 FROM agent_messages WHERE to_agent_id = ? AND created_at >= datetime('now', ?)
|
|
181
|
+
LIMIT 1`)
|
|
182
|
+
.get(lead.actor_id, `${REQUEST_BODY_PREFIX}%`, window, window, window, lead.id, window);
|
|
183
|
+
return recent === undefined ? null : `an automated delivery reached the lead in the last ${seconds} s`;
|
|
184
|
+
}
|
|
185
|
+
function requestBlocker(lead, row, snapshot) {
|
|
186
|
+
if (snapshot === null || snapshot.state !== "idle")
|
|
187
|
+
return { reason: "the lead's turn has not ended", background: false };
|
|
188
|
+
const background = backgroundVeto(snapshot);
|
|
189
|
+
if (background !== null)
|
|
190
|
+
return { reason: background, background: true };
|
|
191
|
+
const policy = passPolicy(row.pass, row.reason);
|
|
192
|
+
if (!policy.requestNeedsQuiet)
|
|
193
|
+
return null;
|
|
194
|
+
if (!olderThan(snapshot.human_prompt_at, policy.humanQuietSeconds)) {
|
|
195
|
+
return { reason: `a human prompt arrived in the last ${policy.humanQuietSeconds} s`, background: false };
|
|
196
|
+
}
|
|
197
|
+
const automation = automationBlocker(lead, policy.automationQuietSeconds);
|
|
198
|
+
return automation === null ? null : { reason: automation, background: false };
|
|
199
|
+
}
|
|
200
|
+
const requestTransaction = db.transaction((row, lead, turns) => {
|
|
201
|
+
const current = readHandoff(row.id);
|
|
202
|
+
if (current === null || current.state !== row.state || current.pass !== row.pass)
|
|
203
|
+
return false;
|
|
204
|
+
const waiting = current.request_wake_id === null
|
|
205
|
+
? undefined
|
|
206
|
+
: db
|
|
207
|
+
.prepare("SELECT id FROM wakes WHERE id = ? AND due_at IS NULL AND fired_at IS NULL AND cancelled_at IS NULL")
|
|
208
|
+
.get(current.request_wake_id);
|
|
209
|
+
let wakeId;
|
|
210
|
+
if (waiting !== undefined) {
|
|
211
|
+
db.prepare("UPDATE wakes SET due_at = datetime('now'), body = ? WHERE id = ?").run(requestBody(current, turns), waiting.id);
|
|
212
|
+
wakeId = waiting.id;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
wakeId = db
|
|
216
|
+
.prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at)
|
|
217
|
+
VALUES (?, ?, ?, 'delay', ?, ?, datetime('now')) RETURNING id`)
|
|
218
|
+
.get(current.project_id, lead.actor_id, requestBody(current, turns), lead.actor_id, lead.tmux_target).id;
|
|
219
|
+
}
|
|
220
|
+
const holds = passPolicy(current.pass, current.reason).holdsFromRequest;
|
|
221
|
+
return casHandoff(current.id, [current.state], {
|
|
222
|
+
state: "requested",
|
|
223
|
+
request_reason: current.reason,
|
|
224
|
+
request_wake_id: wakeId,
|
|
225
|
+
hold_released_at: null,
|
|
226
|
+
// The stop-hold clock runs across passes; only re-arming a released hold restarts it.
|
|
227
|
+
hold_since: !holds ? null : current.hold_released_at !== null || current.hold_since === null ? nowSql() : current.hold_since,
|
|
228
|
+
blocked_reason: null,
|
|
229
|
+
}, { pass: current.pass });
|
|
230
|
+
});
|
|
231
|
+
function maintainHold(row, reason) {
|
|
232
|
+
const holds = handoffHoldsAutomation(row);
|
|
233
|
+
if (holds && row.hold_since === null) {
|
|
234
|
+
casHandoff(row.id, [row.state], { hold_since: nowSql() }, { hold_since: null });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (holds && row.state !== "grace" && olderThan(row.hold_since, STOP_HOLD_CAP_SECONDS)) {
|
|
238
|
+
casHandoff(row.id, [row.state], { hold_released_at: nowSql(), blocked_reason: reason ?? "no quiet moment" }, { hold_released_at: null });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (reason !== row.blocked_reason && reason !== null)
|
|
242
|
+
casHandoff(row.id, [row.state], { blocked_reason: reason });
|
|
243
|
+
}
|
|
244
|
+
// The scheduler's WHEN: arm at warn, wind down at stop, request at the pass's quiet moment, escalate
|
|
245
|
+
// a pass that found none in 10 minutes, and release a hold that has lasted 15. Never touches a pane.
|
|
246
|
+
export function driveLeadHandoff(actorId, budgetFor) {
|
|
247
|
+
const lead = db
|
|
248
|
+
.prepare(`SELECT a.id, a.project_id, a.actor_id, a.command, a.pane_pid, a.tmux_target, a.tmux_socket, p.path
|
|
249
|
+
FROM agents a JOIN projects p ON p.id = a.project_id
|
|
250
|
+
WHERE a.actor_id = ? AND a.kind = 'lead' AND a.status = 'running'`)
|
|
251
|
+
.get(actorId);
|
|
252
|
+
if (lead === undefined)
|
|
253
|
+
return;
|
|
254
|
+
const budget = budgetFor(lead.path);
|
|
255
|
+
const snapshot = readLeadSafetySnapshot(lead.id);
|
|
256
|
+
let row = readActiveHandoff(lead.id);
|
|
257
|
+
if (row === null) {
|
|
258
|
+
const eligibility = handoffEligibility(lead, budget, snapshot);
|
|
259
|
+
if (!eligibility.eligible)
|
|
260
|
+
return;
|
|
261
|
+
armHandoffRequest({
|
|
262
|
+
lead,
|
|
263
|
+
ownerActor: lead.actor_id,
|
|
264
|
+
reason: eligibility.reason,
|
|
265
|
+
epoch: eligibility.epoch,
|
|
266
|
+
requestBody: (id) => requestBody({ id, pass: 1, reason: eligibility.reason }, eligibility.turns),
|
|
267
|
+
});
|
|
268
|
+
row = readActiveHandoff(lead.id);
|
|
269
|
+
if (row === null)
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (!PRE_RESPAWN_STATES.includes(row.state)) {
|
|
273
|
+
settleAfterRespawn(row, lead);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (budget?.auto_handoff !== true) {
|
|
277
|
+
failHandoff(row.id, "lead_turn_budget.auto_handoff is no longer on");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (lead.pane_pid !== row.predecessor_pane_pid || snapshot?.session_id !== row.predecessor_session_id) {
|
|
281
|
+
failHandoff(row.id, "the lead's pane or session changed before the handoff (a restart, /clear or /resume)");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (row.state === "grace") {
|
|
285
|
+
const deadline = (row.grace_seconds ?? 120) + RESPAWN_WAIT_LIMIT_SECONDS + 120;
|
|
286
|
+
const dead = row.owner_pid !== null && !processAlive(row.owner_pid);
|
|
287
|
+
if (dead || olderThan(row.grace_started_at, deadline)) {
|
|
288
|
+
failHandoff(row.id, dead ? "the grace owner process died" : "the grace ran far past its deadline", ["grace"], {
|
|
289
|
+
attempt: row.attempt,
|
|
290
|
+
owner_token: row.owner_token,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const eligibility = handoffEligibility(lead, budget, snapshot);
|
|
296
|
+
if (eligibility.eligible && eligibility.reason === "stop" && row.reason === "warn") {
|
|
297
|
+
casHandoff(row.id, [row.state], { reason: "stop", ...(row.state === "pending" ? { state: "wind_down" } : {}) }, { reason: "warn" });
|
|
298
|
+
row = readHandoff(row.id);
|
|
299
|
+
}
|
|
300
|
+
const blocker = requestBlocker(lead, row, snapshot);
|
|
301
|
+
if (row.state === "requested") {
|
|
302
|
+
maintainHold(row, blocker?.reason ?? null);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (blocker === null) {
|
|
306
|
+
requestTransaction.immediate(row, lead, eligibility.eligible ? eligibility.turns : null);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!blocker.background && olderThan(row.pass_started_at, PASS_TIMEOUT_SECONDS)) {
|
|
310
|
+
casHandoff(row.id, [row.state], {
|
|
311
|
+
pass: row.pass + 1,
|
|
312
|
+
pass_started_at: nowSql(),
|
|
313
|
+
blocked_reason: blocker.reason,
|
|
314
|
+
}, { pass: row.pass });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
maintainHold(row, blocker.reason);
|
|
318
|
+
}
|
|
319
|
+
export const RESPAWN_WAIT_LIMIT_SECONDS = 10 * 60;
|
|
320
|
+
export function missingHeadings(content) {
|
|
321
|
+
const lines = content.split("\n").map((l) => l.replace(/^[#\s*_]+|[\s*_:]+$/g, "").toUpperCase());
|
|
322
|
+
const missing = [];
|
|
323
|
+
let from = 0;
|
|
324
|
+
for (const heading of HANDOFF_HEADINGS) {
|
|
325
|
+
const at = lines.indexOf(heading, from);
|
|
326
|
+
if (at === -1)
|
|
327
|
+
missing.push(heading);
|
|
328
|
+
else
|
|
329
|
+
from = at + 1;
|
|
330
|
+
}
|
|
331
|
+
return missing;
|
|
332
|
+
}
|
|
333
|
+
export function successorPrompt(row) {
|
|
334
|
+
return [
|
|
335
|
+
`You are taking over as this project's lead from session ${row.predecessor_session_id} (hive handoff #${row.id}).`,
|
|
336
|
+
`Read the "${HANDOFF_PAD_NAME}" handoff hive injected at session start (if none arrived, pad_read it by name), ` +
|
|
337
|
+
"then the project's durable state.",
|
|
338
|
+
"Open your first reply with exactly one line:",
|
|
339
|
+
`"Took over from session ${row.predecessor_session_id} at turn ${row.predecessor_turns ?? "?"} (${row.reason}, pass ${row.pass}). ` +
|
|
340
|
+
'Carried over: <a> in flight, <b> queued, <c> questions for you."',
|
|
341
|
+
"counting the IN FLIGHT, QUEUED NEXT and OPEN QUESTIONS FOR THE HUMAN entries, then quote OPEN QUESTIONS FOR THE HUMAN verbatim.",
|
|
342
|
+
"Re-arm everything under SESSION WORK TO RESTORE, check VERIFY ON ARRIVAL, then continue with QUEUED NEXT.",
|
|
343
|
+
].join("\n");
|
|
344
|
+
}
|
|
345
|
+
export function runningLeadByActor(actorId) {
|
|
346
|
+
return db
|
|
347
|
+
.prepare(`SELECT a.id, a.project_id, a.actor_id, a.command, a.pane_pid, a.tmux_target, a.tmux_socket, p.path, p.name
|
|
348
|
+
FROM agents a JOIN projects p ON p.id = a.project_id
|
|
349
|
+
WHERE a.actor_id = ? AND a.kind = 'lead' AND a.status = 'running'`)
|
|
350
|
+
.get(actorId);
|
|
351
|
+
}
|
|
352
|
+
export const readPad = (id) => db.prepare("SELECT id, project_id, name, archived, revision, content FROM pads WHERE id = ?").get(id);
|
|
353
|
+
export function padProblem(pad, projectId, revision) {
|
|
354
|
+
if (pad === undefined || pad.project_id !== projectId)
|
|
355
|
+
return "that pad does not exist in this project";
|
|
356
|
+
if (pad.name !== HANDOFF_PAD_NAME || pad.archived !== 0)
|
|
357
|
+
return `that pad is not the active "${HANDOFF_PAD_NAME}" pad`;
|
|
358
|
+
if (pad.revision !== revision)
|
|
359
|
+
return `that pad is at revision ${pad.revision}, not ${revision}; read it back and pass its current revision`;
|
|
360
|
+
if (pad.content.trim() === "")
|
|
361
|
+
return "the pad is empty";
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
// The final quiet check. A screen or probe that cannot be read waits; it never establishes quiet.
|
|
365
|
+
export async function respawnVerdict(row, lead, budget) {
|
|
366
|
+
if (budget?.auto_handoff !== true)
|
|
367
|
+
return { fail: "lead_turn_budget.auto_handoff is no longer on" };
|
|
368
|
+
if (lead === undefined || lead.pane_pid !== row.predecessor_pane_pid || lead.tmux_target !== row.pane_target) {
|
|
369
|
+
return { fail: "the lead row no longer names the pane this handoff was requested for" };
|
|
370
|
+
}
|
|
371
|
+
if (tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR) !== row.tmux_socket) {
|
|
372
|
+
return { fail: "the grace owner is not talking to the lead's tmux server" };
|
|
373
|
+
}
|
|
374
|
+
const snapshot = readLeadSafetySnapshot(lead.id);
|
|
375
|
+
if (snapshot?.session_id !== row.predecessor_session_id)
|
|
376
|
+
return { fail: "the lead's session changed" };
|
|
377
|
+
if (snapshot.human_prompt_seq !== row.human_prompt_baseline)
|
|
378
|
+
return { postpone: "a human prompt arrived during grace", escalate: true };
|
|
379
|
+
const ownership = rowOwnership(lead);
|
|
380
|
+
if (ownership === "unknown")
|
|
381
|
+
return { wait: "the lead's pane ownership reads unknown" };
|
|
382
|
+
if (ownership !== "live")
|
|
383
|
+
return { fail: `the lead's pane is ${ownership}` };
|
|
384
|
+
if (snapshot.state !== "idle")
|
|
385
|
+
return { wait: "the lead's turn has not ended" };
|
|
386
|
+
const background = backgroundVeto(snapshot);
|
|
387
|
+
if (background !== null)
|
|
388
|
+
return { wait: background };
|
|
389
|
+
const pad = row.pad_id === null ? undefined : readPad(row.pad_id);
|
|
390
|
+
if (padProblem(pad, lead.project_id, row.pad_revision ?? -1) !== null) {
|
|
391
|
+
return { postpone: "the handoff pad changed after the command", escalate: false };
|
|
392
|
+
}
|
|
393
|
+
const policy = quotedPolicy(row);
|
|
394
|
+
if (!olderThan(snapshot.human_prompt_at, policy.humanQuietSeconds)) {
|
|
395
|
+
return { wait: `a human prompt arrived in the last ${policy.humanQuietSeconds} s` };
|
|
396
|
+
}
|
|
397
|
+
const automation = automationBlocker(lead, policy.automationQuietSeconds);
|
|
398
|
+
if (automation !== null)
|
|
399
|
+
return { wait: automation };
|
|
400
|
+
if (paneInCopyMode(lead.tmux_target) !== false)
|
|
401
|
+
return { wait: "the lead's pane is in copy mode or unreadable" };
|
|
402
|
+
const classifier = paneClassifierFor(lead.command);
|
|
403
|
+
if (!classifier)
|
|
404
|
+
return { fail: "hive cannot classify the lead's screen" };
|
|
405
|
+
const choice = classifier.choiceCheck(lead.tmux_target).awaitingChoice;
|
|
406
|
+
if (choice !== false)
|
|
407
|
+
return { wait: choice ? "the lead's pane is waiting on a choice" : "the lead's pane could not be read" };
|
|
408
|
+
const box = classifier.inputBoxState(lead.tmux_target);
|
|
409
|
+
if (holdsHumanInput(box))
|
|
410
|
+
return { postpone: "unsubmitted text is in the lead's input box", escalate: true };
|
|
411
|
+
if (box === null || (box.state !== "empty" && box.state !== "ghost"))
|
|
412
|
+
return { wait: "the lead's input box could not be read" };
|
|
413
|
+
return { go: true };
|
|
414
|
+
}
|
|
415
|
+
const MARKER_SECONDS = 30 * 60;
|
|
416
|
+
// The SessionStart payload for a starting lead whose project holds an active handoff pad: the pad in
|
|
417
|
+
// full, plus what hive itself knows. Never truncated; never shown to a worker.
|
|
418
|
+
export function handoffInjection(leadActor) {
|
|
419
|
+
const lead = runningLeadByActor(leadActor);
|
|
420
|
+
if (lead === undefined)
|
|
421
|
+
return null;
|
|
422
|
+
const pad = db
|
|
423
|
+
.prepare("SELECT id, revision, content, updated_at FROM pads WHERE project_id = ? AND name = ? AND archived = 0")
|
|
424
|
+
.get(lead.project_id, HANDOFF_PAD_NAME);
|
|
425
|
+
if (pad === undefined)
|
|
426
|
+
return null;
|
|
427
|
+
const row = db
|
|
428
|
+
.prepare(`SELECT * FROM lead_handoffs WHERE lead_agent_id = ? AND pad_id = ?
|
|
429
|
+
AND (state IN ('started', 'ambiguous') OR (state = 'failed' AND respawn_claimed_at IS NOT NULL))
|
|
430
|
+
ORDER BY id DESC LIMIT 1`)
|
|
431
|
+
.get(lead.id, pad.id);
|
|
432
|
+
if (row === undefined)
|
|
433
|
+
return null;
|
|
434
|
+
const workers = db
|
|
435
|
+
.prepare("SELECT name, agent_state FROM agents WHERE project_id = ? AND kind = 'agent' AND status = 'running' ORDER BY id")
|
|
436
|
+
.all(lead.project_id);
|
|
437
|
+
const pending = db
|
|
438
|
+
.prepare("SELECT COUNT(*) AS n FROM wakes WHERE deliver_actor = ? AND fired_at IS NULL AND cancelled_at IS NULL")
|
|
439
|
+
.get(lead.actor_id).n;
|
|
440
|
+
const heldWakes = db.prepare("SELECT COUNT(*) AS n FROM wakes WHERE deliver_actor = ? AND held_reason = ? AND cancelled_at IS NULL").get(lead.actor_id, HELD_REASON_HANDOFF).n;
|
|
441
|
+
const heldMessages = db
|
|
442
|
+
.prepare("SELECT COUNT(*) AS n FROM agent_messages WHERE to_agent_id = ? AND delivery_status = 'fallback-pending' AND created_at >= datetime('now', '-7 days')")
|
|
443
|
+
.get(lead.id).n;
|
|
444
|
+
const lines = [`[hive] LEAD HANDOFF: pad "${HANDOFF_PAD_NAME}" #${pad.id} revision ${pad.revision}, written ${pad.updated_at} UTC.`];
|
|
445
|
+
{
|
|
446
|
+
lines.push(`Handed off by session ${row.predecessor_session_id} at turn ${row.predecessor_turns ?? "?"} (${row.reason}, pass ${row.pass})` +
|
|
447
|
+
`${row.started_at ? ` at ${row.started_at} UTC` : ""}; handoff #${row.id} is ${row.state}.`);
|
|
448
|
+
}
|
|
449
|
+
lines.push(workers.length === 0 ? "Workers: none running." : `Workers: ${workers.map((w) => `${w.name} [${w.agent_state}]`).join(", ")}.`, `Wakes pending for this lead: ${pending}. Held for the handoff and ${row.state === "started" ? "delivered to you once your first turn ends" : "released to you now"}: ` +
|
|
450
|
+
`${heldWakes} wake(s), ${heldMessages} message(s).`, row.state === "started" && row.successor_pane_pid === lead.pane_pid
|
|
451
|
+
? `hive archives this pad after your first completed turn. Then append one line to it with ` +
|
|
452
|
+
`pad_append(pad_id: ${pad.id}): "missing from the handoff: <what you had to find yourself>" or "missing from the handoff: none".`
|
|
453
|
+
: `This handoff did not complete (${row.state === "started" ? "its successor is gone" : row.state}${row.failure ? `: ${row.failure}` : ""}), so hive will not archive the pad; ` +
|
|
454
|
+
`archive it with pad_archive(pad_id: ${pad.id}) once you have taken it over.`, "", pad.content);
|
|
455
|
+
return lines.join("\n");
|
|
456
|
+
}
|
|
457
|
+
// An appended line (the successor's gaps note) keeps the delivered text as a prefix and still archives.
|
|
458
|
+
function stillDelivered(row) {
|
|
459
|
+
if (row.pad_id === null || row.pad_revision === null || row.pad_length === null || row.pad_sha256 === null)
|
|
460
|
+
return null;
|
|
461
|
+
const pad = readPad(row.pad_id);
|
|
462
|
+
if (pad === undefined || pad.archived !== 0 || pad.name !== HANDOFF_PAD_NAME || pad.revision < row.pad_revision)
|
|
463
|
+
return null;
|
|
464
|
+
return sha256(pad.content.slice(0, row.pad_length)) === row.pad_sha256 ? { revision: pad.revision } : null;
|
|
465
|
+
}
|
|
466
|
+
const completeTransaction = db.transaction((row, sessionId) => {
|
|
467
|
+
const current = stillDelivered(row);
|
|
468
|
+
const archived = current !== null &&
|
|
469
|
+
db
|
|
470
|
+
.prepare(`UPDATE pads SET archived = 1, revision = revision + 1, updated_by = 'hive', updated_at = datetime('now')
|
|
471
|
+
WHERE id = ? AND revision = ? AND archived = 0 AND name = ?`)
|
|
472
|
+
.run(row.pad_id, current.revision, HANDOFF_PAD_NAME).changes === 1;
|
|
473
|
+
const completedAt = nowSql();
|
|
474
|
+
casHandoff(row.id, ["started"], {
|
|
475
|
+
state: "completed",
|
|
476
|
+
successor_session_id: sessionId,
|
|
477
|
+
completed_at: completedAt,
|
|
478
|
+
hold_released_at: completedAt,
|
|
479
|
+
blocked_reason: null,
|
|
480
|
+
delivered_pad_id: archived ? row.pad_id : null,
|
|
481
|
+
delivered_pad_revision: archived ? row.pad_revision : null,
|
|
482
|
+
failure: archived ? null : "the delivered handoff text was edited after the command, so the pad was left active",
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
// The successor's first completed turn: a Stop in a new session on the successor's pane, after at
|
|
486
|
+
// least one prompt there, with no background work. Anything else leaves the pad active.
|
|
487
|
+
export function completeHandoffOnStop(actorId) {
|
|
488
|
+
const lead = runningLeadByActor(actorId);
|
|
489
|
+
if (lead === undefined)
|
|
490
|
+
return false;
|
|
491
|
+
const row = db.prepare("SELECT * FROM lead_handoffs WHERE lead_agent_id = ? AND state = 'started'").get(lead.id);
|
|
492
|
+
if (row === undefined || lead.pane_pid !== row.successor_pane_pid)
|
|
493
|
+
return false;
|
|
494
|
+
const snapshot = readLeadSafetySnapshot(lead.id);
|
|
495
|
+
if (snapshot === null ||
|
|
496
|
+
snapshot.pane_pid !== lead.pane_pid ||
|
|
497
|
+
snapshot.snapshot_session_id !== snapshot.session_id ||
|
|
498
|
+
snapshot.session_id === row.predecessor_session_id ||
|
|
499
|
+
snapshot.stop_prompt_seq === null ||
|
|
500
|
+
snapshot.stop_prompt_seq < 1 ||
|
|
501
|
+
snapshot.stop_prompt_seq !== snapshot.prompt_seq) {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
completeTransaction.immediate(row, snapshot.session_id);
|
|
505
|
+
return readHandoff(row.id)?.state === "completed";
|
|
506
|
+
}
|
|
507
|
+
export function handoffStatusSegment(leadAgentId) {
|
|
508
|
+
const row = readActiveHandoff(leadAgentId);
|
|
509
|
+
if (row !== null) {
|
|
510
|
+
if (row.state === "started")
|
|
511
|
+
return row.blocked_reason === SUCCESSOR_NOT_READY ? `handoff started: ${SUCCESSOR_NOT_READY}` : "handoff started";
|
|
512
|
+
if (row.hold_released_at !== null)
|
|
513
|
+
return `handoff blocked: ${row.blocked_reason ?? "no quiet moment"}`;
|
|
514
|
+
if (row.state === "grace")
|
|
515
|
+
return "handoff in grace";
|
|
516
|
+
return `handoff ${row.state.replace("_", " ")}`;
|
|
517
|
+
}
|
|
518
|
+
const done = db
|
|
519
|
+
.prepare(`SELECT CAST((julianday('now') - julianday(completed_at)) * 86400 AS INTEGER) AS age FROM lead_handoffs
|
|
520
|
+
WHERE lead_agent_id = ? AND state = 'completed' ORDER BY id DESC LIMIT 1`)
|
|
521
|
+
.get(leadAgentId);
|
|
522
|
+
if (done === undefined || done.age > MARKER_SECONDS)
|
|
523
|
+
return null;
|
|
524
|
+
return `handed off ${done.age < 60 ? `${done.age}s` : `${Math.floor(done.age / 60)}m`} ago`;
|
|
525
|
+
}
|