@cmgmyr/hive 1.10.1 → 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 +2 -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/staleServers.js +122 -0
- 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/leadState.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { liveBackgroundTasks } from "./backgroundTasks.js";
|
|
1
2
|
import { db } from "./db.js";
|
|
2
3
|
import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
|
|
4
|
+
import { postponeGrace, readActiveHandoff } from "./leadHandoffState.js";
|
|
3
5
|
export function readLeadTurnState(agentId) {
|
|
4
6
|
return db.prepare("SELECT * FROM lead_turn_state WHERE agent_id = ?").get(agentId) ?? null;
|
|
5
7
|
}
|
|
@@ -9,7 +11,23 @@ export function leadSessionEnded(row, ownership) {
|
|
|
9
11
|
const turn = readLeadTurnState(row.id);
|
|
10
12
|
return turn?.last_event === "session_end" && turn.pane_pid === row.pane_pid ? { ended_at: turn.changed_at } : null;
|
|
11
13
|
}
|
|
12
|
-
|
|
14
|
+
// The hive-authored prompt shapes conversationHoldsWake excludes, as SQL over an agent_state_log
|
|
15
|
+
// payload column. isHumanPrompt below is its JS twin; a parity test keeps them in step.
|
|
16
|
+
export function humanPromptSql(col) {
|
|
17
|
+
const p = `CASE WHEN json_valid(${col}) THEN COALESCE(json_extract(${col}, '$.prompt'), ${col}) ELSE ${col} END`;
|
|
18
|
+
return (`${p} NOT LIKE '%[hive wake #%' AND ${p} NOT LIKE '[hive:%' AND ${p} NOT LIKE '<task-notification>%' ` +
|
|
19
|
+
`AND ${p} NOT LIKE '<cross-session-message from="%" from-name="hive">' || char(10) || '[hive:worker %] [message #%,%' || char(10) || '</cross-session-message>%'`);
|
|
20
|
+
}
|
|
21
|
+
// Includes SQLite LIKE's ASCII case-insensitivity.
|
|
22
|
+
export function isHumanPrompt(prompt) {
|
|
23
|
+
const p = prompt.toLowerCase();
|
|
24
|
+
if (p.includes("[hive wake #") || p.startsWith("[hive:") || p.startsWith("<task-notification>"))
|
|
25
|
+
return false;
|
|
26
|
+
return !/^<cross-session-message from="[\s\S]*" from-name="hive">\n\[hive:worker [\s\S]*\] \[message #[\s\S]*,[\s\S]*\n<\/cross-session-message>/.test(p);
|
|
27
|
+
}
|
|
28
|
+
// The agent_state_log state label on hive's own first message to a lead; conversationHoldsWake skips it.
|
|
29
|
+
export const FIRST_MESSAGE_LOG_STATE = "first_message";
|
|
30
|
+
export function isHiveFirstMessage(payload) {
|
|
13
31
|
if (typeof payload.prompt !== "string")
|
|
14
32
|
return false;
|
|
15
33
|
const prompt = payload.prompt.trim();
|
|
@@ -41,24 +59,86 @@ export function nextLeadTurn(cur, panePid, event, payload, subagentsLive) {
|
|
|
41
59
|
return null;
|
|
42
60
|
}
|
|
43
61
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
62
|
+
export function readLeadSafetySnapshot(agentId) {
|
|
63
|
+
return (db.prepare("SELECT * FROM lead_turn_state WHERE agent_id = ?").get(agentId) ?? null);
|
|
64
|
+
}
|
|
65
|
+
function applySnapshot(agentId, panePid, event, payload) {
|
|
66
|
+
const row = readLeadSafetySnapshot(agentId);
|
|
67
|
+
const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
|
|
68
|
+
if (row === null || sessionId === "" || row.pane_pid !== panePid || row.session_id !== sessionId)
|
|
48
69
|
return;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
const fresh = row.snapshot_pane_pid !== panePid || row.snapshot_session_id !== sessionId;
|
|
71
|
+
const snap = {
|
|
72
|
+
prompt_seq: fresh ? 0 : row.prompt_seq,
|
|
73
|
+
human_prompt_seq: fresh ? 0 : row.human_prompt_seq,
|
|
74
|
+
human_prompt_at: fresh ? null : row.human_prompt_at,
|
|
75
|
+
transcript_path: fresh ? "" : row.transcript_path,
|
|
76
|
+
stop_prompt_seq: fresh ? null : row.stop_prompt_seq,
|
|
77
|
+
stop_background: fresh ? null : row.stop_background,
|
|
78
|
+
stop_at: fresh ? null : row.stop_at,
|
|
79
|
+
};
|
|
80
|
+
if (typeof payload.transcript_path === "string" && payload.transcript_path !== "") {
|
|
81
|
+
snap.transcript_path = payload.transcript_path;
|
|
82
|
+
}
|
|
83
|
+
const now = db.prepare("SELECT strftime('%Y-%m-%d %H:%M:%f', 'now') AS now").get().now;
|
|
84
|
+
switch (event) {
|
|
85
|
+
case "prompt":
|
|
86
|
+
snap.prompt_seq += 1;
|
|
87
|
+
if (typeof payload.prompt === "string" && !isHiveFirstMessage(payload) && isHumanPrompt(payload.prompt.trim())) {
|
|
88
|
+
snap.human_prompt_seq += 1;
|
|
89
|
+
snap.human_prompt_at = now;
|
|
90
|
+
// A human prompt during grace postpones that attempt at once; the grace owner sees it too.
|
|
91
|
+
const active = readActiveHandoff(agentId);
|
|
92
|
+
if (active?.state === "grace" && active.predecessor_session_id === sessionId) {
|
|
93
|
+
postponeGrace(active, { predecessor_session_id: sessionId }, "a human prompt arrived during grace", true);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
case "stop":
|
|
98
|
+
snap.stop_prompt_seq = snap.prompt_seq;
|
|
99
|
+
snap.stop_at = now;
|
|
100
|
+
// No background_tasks in the payload is unknown evidence, never an empty list.
|
|
101
|
+
snap.stop_background = payload.background_tasks === undefined
|
|
102
|
+
? null
|
|
103
|
+
: JSON.stringify(liveBackgroundTasks(payload.background_tasks));
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
db.prepare(`UPDATE lead_turn_state SET snapshot_pane_pid = ?, snapshot_session_id = ?, prompt_seq = ?, human_prompt_seq = ?,
|
|
107
|
+
human_prompt_at = ?, transcript_path = ?, stop_prompt_seq = ?, stop_background = ?, stop_at = ?
|
|
108
|
+
WHERE agent_id = ?`).run(panePid, sessionId, snap.prompt_seq, snap.human_prompt_seq, snap.human_prompt_at, snap.transcript_path, snap.stop_prompt_seq, snap.stop_background, snap.stop_at, agentId);
|
|
109
|
+
}
|
|
110
|
+
// The states after a respawn claim, in which the predecessor's session is no longer the lead.
|
|
111
|
+
export const HANDED_OFF_STATES_SQL = "('respawning', 'started', 'completed', 'ambiguous')";
|
|
112
|
+
// A predecessor-session hook is fenced from the respawn claim until the handoff completes or fails;
|
|
113
|
+
// the pane pid cannot tell a dying predecessor from an in-pane /resume, so the row's state decides.
|
|
114
|
+
export function predecessorFence(agentId, sessionId) {
|
|
115
|
+
if (typeof sessionId !== "string" || sessionId === "")
|
|
116
|
+
return false;
|
|
117
|
+
return (db
|
|
118
|
+
.prepare(`SELECT 1 AS hit FROM lead_handoffs WHERE lead_agent_id = ? AND predecessor_session_id = ?
|
|
119
|
+
AND state IN ('respawning', 'started', 'ambiguous')`)
|
|
120
|
+
.get(agentId, sessionId) !== undefined);
|
|
121
|
+
}
|
|
122
|
+
const applyInTransaction = db.transaction((agentId, panePid, event, payload, subagentsLive, fenced) => {
|
|
123
|
+
if (fenced)
|
|
55
124
|
return;
|
|
125
|
+
const cur = readLeadTurnState(agentId);
|
|
126
|
+
const next = nextLeadTurn(cur, panePid, event, payload, subagentsLive);
|
|
127
|
+
const unchanged = next === null ||
|
|
128
|
+
(cur !== null &&
|
|
129
|
+
cur.pane_pid === next.pane_pid &&
|
|
130
|
+
cur.session_id === next.session_id &&
|
|
131
|
+
cur.state === next.state &&
|
|
132
|
+
cur.idle_seq === next.idle_seq &&
|
|
133
|
+
(event !== "session_end" || cur.last_event === "session_end"));
|
|
134
|
+
if (!unchanged) {
|
|
135
|
+
db.prepare(`INSERT INTO lead_turn_state (agent_id, pane_pid, session_id, state, idle_seq, last_event, changed_at)
|
|
136
|
+
VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
137
|
+
ON CONFLICT(agent_id) DO UPDATE SET pane_pid = excluded.pane_pid, session_id = excluded.session_id,
|
|
138
|
+
state = excluded.state, idle_seq = excluded.idle_seq, last_event = excluded.last_event,
|
|
139
|
+
changed_at = excluded.changed_at`).run(agentId, next.pane_pid, next.session_id, next.state, next.idle_seq, event);
|
|
56
140
|
}
|
|
57
|
-
|
|
58
|
-
VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
59
|
-
ON CONFLICT(agent_id) DO UPDATE SET pane_pid = excluded.pane_pid, session_id = excluded.session_id,
|
|
60
|
-
state = excluded.state, idle_seq = excluded.idle_seq, last_event = excluded.last_event,
|
|
61
|
-
changed_at = excluded.changed_at`).run(agentId, next.pane_pid, next.session_id, next.state, next.idle_seq, event);
|
|
141
|
+
applySnapshot(agentId, panePid, event, payload);
|
|
62
142
|
});
|
|
63
143
|
export function applyLeadHook(actorId, event, payload, subagentsLive) {
|
|
64
144
|
const lead = db
|
|
@@ -66,5 +146,6 @@ export function applyLeadHook(actorId, event, payload, subagentsLive) {
|
|
|
66
146
|
.get(actorId);
|
|
67
147
|
if (!lead)
|
|
68
148
|
return;
|
|
69
|
-
|
|
149
|
+
const fenced = predecessorFence(lead.id, payload.session_id);
|
|
150
|
+
applyInTransaction.immediate(lead.id, lead.pane_pid, event, payload, subagentsLive, fenced);
|
|
70
151
|
}
|
package/dist/portfolio.js
CHANGED
|
@@ -3,6 +3,7 @@ import { listProjects } from "./context.js";
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { parseTags } from "./result.js";
|
|
5
5
|
import { OPEN_BLOCKERS_SQL } from "./tools/todos.js";
|
|
6
|
+
import { pendingQuestionCount } from "./questions.js";
|
|
6
7
|
import { leadSessionEnded, readLeadTurnState } from "./leadState.js";
|
|
7
8
|
import { liveTargets, ownershipLiveness, rowOwnership } from "./tmux.js";
|
|
8
9
|
export const PORTFOLIO_REASONS = [
|
|
@@ -191,6 +192,7 @@ function projectRow(project, now, snapshot) {
|
|
|
191
192
|
workers,
|
|
192
193
|
todos,
|
|
193
194
|
needs_human: tagged.length,
|
|
195
|
+
questions_pending: pendingQuestionCount(project.id),
|
|
194
196
|
needs_human_items: tagged.slice(0, NEEDS_HUMAN_ITEM_CAP).map((t) => ({
|
|
195
197
|
todo_id: t.id,
|
|
196
198
|
title: t.title,
|
package/dist/projectRemove.js
CHANGED
package/dist/projectYml.js
CHANGED
|
@@ -180,7 +180,14 @@ function applyLayer(root, path, source, config, sources, warnings) {
|
|
|
180
180
|
}
|
|
181
181
|
else if (isMapping(value) && typeof value.warn === "number" && Number.isInteger(value.warn) && value.warn > 0 &&
|
|
182
182
|
typeof value.stop === "number" && Number.isInteger(value.stop) && value.stop > value.warn) {
|
|
183
|
-
|
|
183
|
+
const budget = { warn: value.warn, stop: value.stop };
|
|
184
|
+
if (value.auto_handoff !== undefined) {
|
|
185
|
+
if (typeof value.auto_handoff === "boolean")
|
|
186
|
+
budget.auto_handoff = value.auto_handoff;
|
|
187
|
+
else
|
|
188
|
+
warn(`lead_turn_budget.auto_handoff must be true or false; ignoring "${String(value.auto_handoff)}", so automatic handoff stays off.`);
|
|
189
|
+
}
|
|
190
|
+
config.lead_turn_budget = budget;
|
|
184
191
|
sources.lead_turn_budget = source;
|
|
185
192
|
}
|
|
186
193
|
else {
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { HUMAN_ACTOR_PREFIX } from "./context.js";
|
|
3
|
+
import { assertSafeWakeBody, resolveWakeDelivery } from "./tools/wakes.js";
|
|
4
|
+
const QUESTION_STATE_CONFLICT = "QUESTION_STATE_CONFLICT";
|
|
5
|
+
const HEADER_MAX_CHARS = 12;
|
|
6
|
+
const MIN_OPTIONS = 2;
|
|
7
|
+
const MAX_OPTIONS = 4;
|
|
8
|
+
function toRecord(row) {
|
|
9
|
+
const { ref_kind, ref_number, options, ...rest } = row;
|
|
10
|
+
return {
|
|
11
|
+
...rest,
|
|
12
|
+
options: options === null ? null : JSON.parse(options),
|
|
13
|
+
ref: ref_kind === null || ref_number === null ? null : { kind: ref_kind, number: ref_number },
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const blank = (s) => typeof s !== "string" || s.trim() === "";
|
|
17
|
+
function validateRef(ref) {
|
|
18
|
+
if (ref === undefined)
|
|
19
|
+
return;
|
|
20
|
+
if (ref.kind !== "todo" && ref.kind !== "pr")
|
|
21
|
+
throw new Error(`ref.kind must be "todo" or "pr", got ${JSON.stringify(ref.kind)}.`);
|
|
22
|
+
if (!Number.isSafeInteger(ref.number) || ref.number <= 0)
|
|
23
|
+
throw new Error("ref.number must be a positive safe integer.");
|
|
24
|
+
}
|
|
25
|
+
function validateInput(input) {
|
|
26
|
+
if (input.kind !== "choice" && input.kind !== "free_text") {
|
|
27
|
+
throw new Error(`kind must be "choice" or "free_text", got ${JSON.stringify(input.kind)}.`);
|
|
28
|
+
}
|
|
29
|
+
if (blank(input.question))
|
|
30
|
+
throw new Error("question cannot be blank.");
|
|
31
|
+
if (blank(input.header))
|
|
32
|
+
throw new Error("header cannot be blank.");
|
|
33
|
+
if ([...input.header].length > HEADER_MAX_CHARS) {
|
|
34
|
+
throw new Error(`header is ${[...input.header].length} characters; the limit is ${HEADER_MAX_CHARS}.`);
|
|
35
|
+
}
|
|
36
|
+
if (blank(input.default_answer))
|
|
37
|
+
throw new Error("default_answer cannot be blank: every question carries its default.");
|
|
38
|
+
validateRef(input.ref);
|
|
39
|
+
const wakeBound = [input.question, input.header, input.default_answer];
|
|
40
|
+
if (input.kind === "free_text") {
|
|
41
|
+
if (input.options !== undefined)
|
|
42
|
+
throw new Error("a free_text question takes no options.");
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const options = input.options;
|
|
46
|
+
if (!Array.isArray(options) || options.length < MIN_OPTIONS || options.length > MAX_OPTIONS) {
|
|
47
|
+
throw new Error(`a choice question needs ${MIN_OPTIONS} to ${MAX_OPTIONS} options, got ${Array.isArray(options) ? options.length : 0}.`);
|
|
48
|
+
}
|
|
49
|
+
const labels = new Set();
|
|
50
|
+
for (const o of options) {
|
|
51
|
+
if (blank(o.label))
|
|
52
|
+
throw new Error("an option label cannot be blank.");
|
|
53
|
+
if (blank(o.description))
|
|
54
|
+
throw new Error(`option "${o.label}" needs a description.`);
|
|
55
|
+
if (labels.has(o.label))
|
|
56
|
+
throw new Error(`option label "${o.label}" is duplicated; labels must be distinct.`);
|
|
57
|
+
labels.add(o.label);
|
|
58
|
+
wakeBound.push(o.label, o.description);
|
|
59
|
+
}
|
|
60
|
+
const recommended = options.filter((o) => o.recommended);
|
|
61
|
+
if (recommended.length !== 1) {
|
|
62
|
+
throw new Error(`exactly one option must be recommended, got ${recommended.length}.`);
|
|
63
|
+
}
|
|
64
|
+
if (input.default_answer !== recommended[0].label) {
|
|
65
|
+
throw new Error(`default_answer must be the recommended option's label ("${recommended[0].label}").`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const text of wakeBound)
|
|
69
|
+
assertSafeWakeBody(text);
|
|
70
|
+
}
|
|
71
|
+
const tokensOf = (text) => new Set((text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter((t) => [...t].length >= 4));
|
|
72
|
+
const normalized = (text) => text.trim().replace(/\s+/g, " ").toLowerCase();
|
|
73
|
+
export function looksSimilar(a, b) {
|
|
74
|
+
if (normalized(a) === normalized(b))
|
|
75
|
+
return true;
|
|
76
|
+
const ta = tokensOf(a);
|
|
77
|
+
const tb = tokensOf(b);
|
|
78
|
+
let shared = 0;
|
|
79
|
+
for (const t of ta)
|
|
80
|
+
if (tb.has(t))
|
|
81
|
+
shared++;
|
|
82
|
+
const union = ta.size + tb.size - shared;
|
|
83
|
+
return union > 0 && shared / union >= 0.5;
|
|
84
|
+
}
|
|
85
|
+
function similarPendingIds(projectId, selfId, question) {
|
|
86
|
+
const rows = db
|
|
87
|
+
.prepare("SELECT id, question FROM questions WHERE project_id = ? AND state = 'pending' AND id != ? ORDER BY id")
|
|
88
|
+
.all(projectId, selfId);
|
|
89
|
+
return rows.filter((r) => looksSimilar(question, r.question)).map((r) => r.id);
|
|
90
|
+
}
|
|
91
|
+
function runningAgentOf(projectId, actor) {
|
|
92
|
+
const row = db
|
|
93
|
+
.prepare("SELECT id FROM agents WHERE project_id = ? AND actor_id = ? AND status = 'running' ORDER BY id DESC LIMIT 1")
|
|
94
|
+
.get(projectId, actor);
|
|
95
|
+
return row?.id ?? null;
|
|
96
|
+
}
|
|
97
|
+
const optionsJson = (input) => (input.kind === "choice" ? JSON.stringify(input.options) : null);
|
|
98
|
+
function pendingIdForRef(projectId, ref) {
|
|
99
|
+
const row = db
|
|
100
|
+
.prepare("SELECT id FROM questions WHERE project_id = ? AND ref_kind = ? AND ref_number = ? AND state = 'pending'")
|
|
101
|
+
.get(projectId, ref.kind, ref.number);
|
|
102
|
+
return row?.id;
|
|
103
|
+
}
|
|
104
|
+
const upsertQuestion = db.transaction((projectId, actor, input, askerChecked) => {
|
|
105
|
+
if (input.ref) {
|
|
106
|
+
const existing = pendingIdForRef(projectId, input.ref);
|
|
107
|
+
if (existing !== undefined) {
|
|
108
|
+
db.prepare(`UPDATE questions SET kind = ?, question = ?, header = ?, options = ?, default_answer = ?,
|
|
109
|
+
updated_at = datetime('now'), updated_by = ? WHERE id = ?`).run(input.kind, input.question, input.header, optionsJson(input), input.default_answer, actor, existing);
|
|
110
|
+
return { id: existing, created: false };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!askerChecked) {
|
|
114
|
+
throw new Error(`${QUESTION_STATE_CONFLICT}: the pending question for this ref changed while it was being asked; ask again.`);
|
|
115
|
+
}
|
|
116
|
+
const row = db
|
|
117
|
+
.prepare(`INSERT INTO questions (project_id, asker, kind, question, header, options, default_answer,
|
|
118
|
+
ref_kind, ref_number, updated_by)
|
|
119
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`)
|
|
120
|
+
.get(projectId, actor, input.kind, input.question, input.header, optionsJson(input), input.default_answer, input.ref?.kind ?? null, input.ref?.number ?? null, actor);
|
|
121
|
+
return { id: row.id, created: true };
|
|
122
|
+
});
|
|
123
|
+
export function askQuestion(projectId, actor, input) {
|
|
124
|
+
validateInput(input);
|
|
125
|
+
const refTaken = input.ref !== undefined && pendingIdForRef(projectId, input.ref) !== undefined;
|
|
126
|
+
let askerChecked = false;
|
|
127
|
+
if (!refTaken) {
|
|
128
|
+
resolveWakeDelivery(projectId);
|
|
129
|
+
askerChecked = runningAgentOf(projectId, actor) !== null;
|
|
130
|
+
if (!askerChecked) {
|
|
131
|
+
throw new Error("This session has no running hive agent row, so an answer could never be woken back to it; a question needs an asker that can be woken.");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const { id, created } = upsertQuestion.immediate(projectId, actor, input, askerChecked);
|
|
135
|
+
return { question_id: id, created, similar_question_ids: similarPendingIds(projectId, id, input.question) };
|
|
136
|
+
}
|
|
137
|
+
function fetchRow(projectId, id) {
|
|
138
|
+
const row = db.prepare("SELECT * FROM questions WHERE project_id = ? AND id = ?").get(projectId, id);
|
|
139
|
+
if (!row)
|
|
140
|
+
throw new Error(`No question ${id} in this project. Call question_list.`);
|
|
141
|
+
return row;
|
|
142
|
+
}
|
|
143
|
+
export function getQuestion(projectId, id) {
|
|
144
|
+
return toRecord(fetchRow(projectId, id));
|
|
145
|
+
}
|
|
146
|
+
export function listQuestions(projectId) {
|
|
147
|
+
const rows = db
|
|
148
|
+
.prepare("SELECT * FROM questions WHERE project_id = ? AND state IN ('pending', 'defaulted') ORDER BY (state = 'defaulted'), id")
|
|
149
|
+
.all(projectId);
|
|
150
|
+
return rows.map(toRecord);
|
|
151
|
+
}
|
|
152
|
+
export function pendingQuestionCount(projectId) {
|
|
153
|
+
return db.prepare("SELECT COUNT(*) AS n FROM questions WHERE project_id = ? AND state = 'pending'").get(projectId).n;
|
|
154
|
+
}
|
|
155
|
+
const TEXT_PATCH_KEYS = ["question", "header", "options", "default_answer"];
|
|
156
|
+
const patchQuestion = db.transaction((projectId, actor, id, patch) => {
|
|
157
|
+
const current = toRecord(fetchRow(projectId, id));
|
|
158
|
+
const editsText = TEXT_PATCH_KEYS.some((k) => patch[k] !== undefined);
|
|
159
|
+
if (editsText && current.state !== "pending") {
|
|
160
|
+
throw new Error(`${QUESTION_STATE_CONFLICT}: question ${id} is ${current.state}; only a pending question's text can be edited.`);
|
|
161
|
+
}
|
|
162
|
+
if (patch.state !== undefined) {
|
|
163
|
+
if (patch.state !== "defaulted" && patch.state !== "withdrawn") {
|
|
164
|
+
throw new Error(`state must be "defaulted" or "withdrawn", got ${JSON.stringify(patch.state)}.`);
|
|
165
|
+
}
|
|
166
|
+
const allowed = patch.state === "withdrawn" ? ["pending", "defaulted"] : ["pending"];
|
|
167
|
+
if (!allowed.includes(current.state)) {
|
|
168
|
+
throw new Error(`${QUESTION_STATE_CONFLICT}: question ${id} is ${current.state} and cannot become ${patch.state}.`);
|
|
169
|
+
}
|
|
170
|
+
if (actor !== current.asker && !actor.startsWith(HUMAN_ACTOR_PREFIX)) {
|
|
171
|
+
throw new Error(`Only the asking session (${current.asker}) or the human can mark question ${id} ${patch.state}.`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
let merged = null;
|
|
175
|
+
if (editsText) {
|
|
176
|
+
if (current.kind === "free_text" && patch.options !== undefined)
|
|
177
|
+
throw new Error("a free_text question takes no options.");
|
|
178
|
+
const base = {
|
|
179
|
+
question: patch.question ?? current.question,
|
|
180
|
+
header: patch.header ?? current.header,
|
|
181
|
+
default_answer: patch.default_answer ?? current.default_answer,
|
|
182
|
+
...(current.ref ? { ref: current.ref } : {}),
|
|
183
|
+
};
|
|
184
|
+
merged =
|
|
185
|
+
current.kind === "choice"
|
|
186
|
+
? { kind: "choice", ...base, options: patch.options ?? current.options }
|
|
187
|
+
: { kind: "free_text", ...base };
|
|
188
|
+
validateInput(merged);
|
|
189
|
+
}
|
|
190
|
+
const sets = ["updated_at = datetime('now')", "updated_by = ?"];
|
|
191
|
+
const params = [actor];
|
|
192
|
+
if (merged) {
|
|
193
|
+
sets.push("question = ?", "header = ?", "options = ?", "default_answer = ?");
|
|
194
|
+
params.push(merged.question, merged.header, optionsJson(merged), merged.default_answer);
|
|
195
|
+
}
|
|
196
|
+
if (patch.state !== undefined) {
|
|
197
|
+
sets.push("state = ?");
|
|
198
|
+
params.push(patch.state);
|
|
199
|
+
}
|
|
200
|
+
db.prepare(`UPDATE questions SET ${sets.join(", ")} WHERE id = ?`).run(...params, id);
|
|
201
|
+
return { question_id: id, state: patch.state ?? current.state };
|
|
202
|
+
});
|
|
203
|
+
export function updateQuestion(projectId, actor, id, patch) {
|
|
204
|
+
if (Object.keys(patch).filter((k) => patch[k] !== undefined).length === 0) {
|
|
205
|
+
throw new Error("an empty patch changes nothing; pass at least one field to change.");
|
|
206
|
+
}
|
|
207
|
+
return patchQuestion.immediate(projectId, actor, id, patch);
|
|
208
|
+
}
|
|
209
|
+
export function answerWakeBody(id, question, answer, notes) {
|
|
210
|
+
const head = `Question #${id} answered.\nQuestion: ${question}\nAnswer: ${answer}`;
|
|
211
|
+
return notes !== null && notes.trim() !== "" ? `${head}\nNotes: ${notes}` : head;
|
|
212
|
+
}
|
|
213
|
+
function planAnswerWake(projectId, row, actor) {
|
|
214
|
+
if (actor === row.asker)
|
|
215
|
+
return { skipped: "answered by the asker" };
|
|
216
|
+
const agentId = runningAgentOf(projectId, row.asker);
|
|
217
|
+
if (agentId === null)
|
|
218
|
+
return { skipped: `the asker ${row.asker} has no running agent` };
|
|
219
|
+
try {
|
|
220
|
+
return { delivery: resolveWakeDelivery(projectId, agentId) };
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
return { skipped: e instanceof Error ? e.message : String(e) };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const commitAnswer = db.transaction((projectId, actor, id, answer, notes, plan) => {
|
|
227
|
+
const row = fetchRow(projectId, id);
|
|
228
|
+
if (row.state !== "pending" && row.state !== "defaulted") {
|
|
229
|
+
throw new Error(`${QUESTION_STATE_CONFLICT}: question ${id} is already ${row.state}.`);
|
|
230
|
+
}
|
|
231
|
+
db.prepare(`UPDATE questions SET state = 'answered', answer = ?, notes = ?, answered_by = ?, updated_by = ?,
|
|
232
|
+
updated_at = datetime('now') WHERE id = ?`).run(answer, notes, actor, actor, id);
|
|
233
|
+
if ("skipped" in plan)
|
|
234
|
+
return { question_id: id, wake_id: null, wake_skipped: plan.skipped };
|
|
235
|
+
const wake = db
|
|
236
|
+
.prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at)
|
|
237
|
+
VALUES (?, ?, ?, 'delay', ?, ?, datetime('now')) RETURNING id`)
|
|
238
|
+
.get(projectId, actor, answerWakeBody(id, row.question, answer, notes), plan.delivery.actor, plan.delivery.pane);
|
|
239
|
+
return { question_id: id, wake_id: wake.id };
|
|
240
|
+
});
|
|
241
|
+
export function answerQuestion(projectId, actor, id, answer, notes) {
|
|
242
|
+
if (blank(answer))
|
|
243
|
+
throw new Error("answer cannot be blank.");
|
|
244
|
+
const storedNotes = notes === undefined || notes.trim() === "" ? null : notes;
|
|
245
|
+
assertSafeWakeBody(answer);
|
|
246
|
+
if (storedNotes !== null)
|
|
247
|
+
assertSafeWakeBody(storedNotes);
|
|
248
|
+
const row = fetchRow(projectId, id);
|
|
249
|
+
if (row.state !== "pending" && row.state !== "defaulted") {
|
|
250
|
+
throw new Error(`${QUESTION_STATE_CONFLICT}: question ${id} is already ${row.state}.`);
|
|
251
|
+
}
|
|
252
|
+
const plan = planAnswerWake(projectId, row, actor);
|
|
253
|
+
return commitAnswer.immediate(projectId, actor, id, answer, storedNotes, plan);
|
|
254
|
+
}
|
package/dist/scheduler.js
CHANGED
|
@@ -18,7 +18,9 @@ import { evaluateLeadWatch, leadSubscription } from "./leadWatch.js";
|
|
|
18
18
|
import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SECONDS, } from "./backgroundTasks.js";
|
|
19
19
|
import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
|
|
20
20
|
import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress, SOCKET_WAKE_FOOTER } from "./claudeWake.js";
|
|
21
|
-
import {
|
|
21
|
+
import { driveLeadHandoff } from "./leadHandoff.js";
|
|
22
|
+
import { handoffHoldsWake, HELD_REASON_HANDOFF } from "./leadHandoffState.js";
|
|
23
|
+
import { FIRST_MESSAGE_LOG_STATE, humanPromptSql, readLeadTurnState } from "./leadState.js";
|
|
22
24
|
import { retryQuietLeadMessages } from "./leadMessageDelivery.js";
|
|
23
25
|
import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
|
|
24
26
|
import { transcriptDir, readContextFill } from "./transcript.js";
|
|
@@ -481,6 +483,17 @@ export function checkConfirmations() {
|
|
|
481
483
|
catch {
|
|
482
484
|
}
|
|
483
485
|
}
|
|
486
|
+
// Only the lead's own server drives its handoff; every server honours the hold it produces.
|
|
487
|
+
function driveOwnLeadHandoff() {
|
|
488
|
+
const actor = process.env.HIVE_AGENT_ID;
|
|
489
|
+
if (!actor || !isLeadActorId(actor))
|
|
490
|
+
return;
|
|
491
|
+
try {
|
|
492
|
+
driveLeadHandoff(actor, (path) => loadProjectYml(path).config?.lead_turn_budget ?? null);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
}
|
|
496
|
+
}
|
|
484
497
|
export async function tick(snapshot) {
|
|
485
498
|
if (ticking)
|
|
486
499
|
return;
|
|
@@ -496,6 +509,7 @@ export async function tick(snapshot) {
|
|
|
496
509
|
janitor(snapshot);
|
|
497
510
|
if (snapshot)
|
|
498
511
|
reportRunningBuildChange(snapshot);
|
|
512
|
+
driveOwnLeadHandoff();
|
|
499
513
|
checkConfirmations();
|
|
500
514
|
pruneStateLog();
|
|
501
515
|
maybeBackupHourly(db, dataDir);
|
|
@@ -667,11 +681,9 @@ function conversationHoldsWake(timer, snapshot) {
|
|
|
667
681
|
return false;
|
|
668
682
|
}
|
|
669
683
|
return (stmt(`SELECT 1 AS hit FROM agent_state_log
|
|
670
|
-
WHERE actor_id = ? AND event = 'prompt' AND
|
|
671
|
-
AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '<task-notification>%'
|
|
672
|
-
AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '<cross-session-message from="%" from-name="hive">' || char(10) || '[hive:worker %] [message #%,%' || char(10) || '</cross-session-message>%'
|
|
684
|
+
WHERE actor_id = ? AND event = 'prompt' AND state != ? AND ${humanPromptSql("payload")}
|
|
673
685
|
AND created_at >= datetime('now', ?)
|
|
674
|
-
ORDER BY id DESC LIMIT 1`).get(timer.deliver_actor, CONVERSATION_HOLD_TTL) !== undefined);
|
|
686
|
+
ORDER BY id DESC LIMIT 1`).get(timer.deliver_actor, FIRST_MESSAGE_LOG_STATE, CONVERSATION_HOLD_TTL) !== undefined);
|
|
675
687
|
}
|
|
676
688
|
function heldTarget(timer) {
|
|
677
689
|
const row = stmt(`SELECT id, name, kind, COALESCE(state_changed_at, '') AS blocked_since FROM agents WHERE actor_id = ?
|
|
@@ -1530,6 +1542,10 @@ function deliverable(timer, snapshot, choices) {
|
|
|
1530
1542
|
else {
|
|
1531
1543
|
owner = { rowId: row.deliver_row_id, identity: deliveryIdentity(row) };
|
|
1532
1544
|
}
|
|
1545
|
+
if (handoffHoldsWake(timer.id, row.deliver_row_id)) {
|
|
1546
|
+
holdTimer(timer, HELD_REASON_HANDOFF);
|
|
1547
|
+
return { ok: false };
|
|
1548
|
+
}
|
|
1533
1549
|
// Above both pane reads: this population must not be captured at all, and the verdicts would be
|
|
1534
1550
|
// wrong in opposite directions if they were.
|
|
1535
1551
|
if (!screenClassifiable(timer.deliver_command)) {
|
|
@@ -1609,6 +1625,10 @@ async function withPaneClaim(timer, fn) {
|
|
|
1609
1625
|
}
|
|
1610
1626
|
}
|
|
1611
1627
|
async function fireDelay(timer, snapshot, choices) {
|
|
1628
|
+
if (handoffHoldsWake(timer.id, timer.deliver_row_id)) {
|
|
1629
|
+
holdTimer(timer, HELD_REASON_HANDOFF);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1612
1632
|
const disposition = noticeDisposition(timer);
|
|
1613
1633
|
if (disposition === "aged") {
|
|
1614
1634
|
try {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { DEFAULT_DATA_DIR } from "./dataDir.js";
|
|
6
|
+
export const STALE_SERVER_BOUNDARY_SLACK_MS = 3000;
|
|
7
|
+
const serverEntrypoint = resolve(dirname(fileURLToPath(import.meta.url)), "index.js");
|
|
8
|
+
export function elapsedSeconds(value) {
|
|
9
|
+
const dayForm = /^(\d+)-(\d{1,2}):(\d{2}):(\d{2})$/.exec(value);
|
|
10
|
+
const fields = dayForm ? dayForm.slice(2).map(Number) : value.split(":").map(Number);
|
|
11
|
+
if ((!dayForm && fields.length !== 2 && fields.length !== 3) || fields.some((field) => !Number.isSafeInteger(field) || field < 0)) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
let total;
|
|
15
|
+
if (dayForm) {
|
|
16
|
+
const [hours, minutes, seconds] = fields;
|
|
17
|
+
if (hours > 23 || minutes > 59 || seconds > 59)
|
|
18
|
+
return null;
|
|
19
|
+
total = (Number(dayForm[1]) * 24 + hours) * 3600 + minutes * 60 + seconds;
|
|
20
|
+
}
|
|
21
|
+
else if (fields.length === 2) {
|
|
22
|
+
const [minutes, seconds] = fields;
|
|
23
|
+
if (seconds > 59)
|
|
24
|
+
return null;
|
|
25
|
+
total = minutes * 60 + seconds;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const [hours, minutes, seconds] = fields;
|
|
29
|
+
if (minutes > 59 || seconds > 59)
|
|
30
|
+
return null;
|
|
31
|
+
total = hours * 3600 + minutes * 60 + seconds;
|
|
32
|
+
}
|
|
33
|
+
return Number.isSafeInteger(total) ? total : null;
|
|
34
|
+
}
|
|
35
|
+
function appliedAtInstant(appliedAt) {
|
|
36
|
+
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(appliedAt))
|
|
37
|
+
return null;
|
|
38
|
+
const instant = Date.parse(`${appliedAt.replace(" ", "T")}Z`);
|
|
39
|
+
return Number.isFinite(instant) ? instant : null;
|
|
40
|
+
}
|
|
41
|
+
function runsThisServer(command) {
|
|
42
|
+
const escapedPath = serverEntrypoint.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
43
|
+
const script = new RegExp(`(?:^|\\s|["'])${escapedPath}(?=$|\\s|["'])`).exec(command);
|
|
44
|
+
if (!script)
|
|
45
|
+
return false;
|
|
46
|
+
const executable = command.slice(0, script.index + script[0].indexOf(serverEntrypoint)).trim();
|
|
47
|
+
return /(?:^|[\\/])node(?:\.exe)?$/.test(executable);
|
|
48
|
+
}
|
|
49
|
+
function canonicalDataDir(path) {
|
|
50
|
+
try {
|
|
51
|
+
return realpathSync(path);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return resolve(path);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function processUsesDataDir(psOutput, dataDir) {
|
|
58
|
+
if (psOutput.trim() === "")
|
|
59
|
+
return false;
|
|
60
|
+
if (!runsThisServer(psOutput))
|
|
61
|
+
return false;
|
|
62
|
+
const store = /(?:^|\s)HIVE_DATA_DIR=(.*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*=|$)/.exec(psOutput);
|
|
63
|
+
const path = store ? store[1].trim() || DEFAULT_DATA_DIR : /(?:^|\s)PATH=/.test(psOutput) ? DEFAULT_DATA_DIR : null;
|
|
64
|
+
if (path === null)
|
|
65
|
+
return null;
|
|
66
|
+
return canonicalDataDir(path) === canonicalDataDir(dataDir);
|
|
67
|
+
}
|
|
68
|
+
export function parseStaleHiveServers(psOutput, appliedAt, observedAt = Date.now()) {
|
|
69
|
+
const newestMigration = appliedAtInstant(appliedAt);
|
|
70
|
+
if (newestMigration === null)
|
|
71
|
+
return { skipped: "the newest migration time is unavailable or invalid" };
|
|
72
|
+
if (!Number.isFinite(observedAt))
|
|
73
|
+
return { skipped: "the process snapshot time is invalid" };
|
|
74
|
+
const rows = [];
|
|
75
|
+
for (const line of psOutput.split(/\r?\n/)) {
|
|
76
|
+
if (line.trim() === "")
|
|
77
|
+
continue;
|
|
78
|
+
const row = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line);
|
|
79
|
+
if (!row)
|
|
80
|
+
return { skipped: "ps returned a row that could not be parsed" };
|
|
81
|
+
const elapsed = elapsedSeconds(row[3]);
|
|
82
|
+
if (elapsed === null)
|
|
83
|
+
return { skipped: "ps returned an invalid elapsed time" };
|
|
84
|
+
rows.push({ pid: Number(row[1]), ppid: Number(row[2]), elapsed, command: row[4] });
|
|
85
|
+
}
|
|
86
|
+
const commands = new Map(rows.map((row) => [row.pid, row.command]));
|
|
87
|
+
const servers = rows
|
|
88
|
+
.map((row) => ({ ...row, started: observedAt - row.elapsed * 1000 }))
|
|
89
|
+
.filter((row) => row.started < newestMigration - STALE_SERVER_BOUNDARY_SLACK_MS && runsThisServer(row.command))
|
|
90
|
+
.map(({ elapsed: _elapsed, ...row }) => ({
|
|
91
|
+
...row,
|
|
92
|
+
startedAt: new Date(row.started).toISOString(),
|
|
93
|
+
parentCommand: commands.get(row.ppid) ?? null,
|
|
94
|
+
}));
|
|
95
|
+
return { servers };
|
|
96
|
+
}
|
|
97
|
+
export function staleHiveServers(appliedAt, dataDir = DEFAULT_DATA_DIR) {
|
|
98
|
+
if (appliedAt === null)
|
|
99
|
+
return { skipped: "the newest migration time is unavailable" };
|
|
100
|
+
try {
|
|
101
|
+
const psOutput = execFileSync("ps", ["-eo", "pid=,ppid=,etime=,command="], { encoding: "utf8" });
|
|
102
|
+
const parsed = parseStaleHiveServers(psOutput, appliedAt, Date.now());
|
|
103
|
+
if ("skipped" in parsed)
|
|
104
|
+
return parsed;
|
|
105
|
+
const servers = [];
|
|
106
|
+
for (const server of parsed.servers) {
|
|
107
|
+
const envOutput = execFileSync("ps", ["eww", "-p", String(server.pid), "-o", "command="], {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
});
|
|
110
|
+
const sameStore = processUsesDataDir(envOutput, dataDir);
|
|
111
|
+
if (sameStore === null)
|
|
112
|
+
return { skipped: "ps returned environment output that could not be parsed" };
|
|
113
|
+
if (sameStore)
|
|
114
|
+
servers.push(server);
|
|
115
|
+
}
|
|
116
|
+
return { servers };
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
120
|
+
return { skipped: `ps could not be run: ${reason}` };
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/tools/meta.js
CHANGED
|
@@ -36,6 +36,9 @@ export const ACTOR_OWNER_COLUMNS = [
|
|
|
36
36
|
["wakes", "owner"],
|
|
37
37
|
["wakes", "deliver_actor"],
|
|
38
38
|
["agent_state_log", "actor_id"],
|
|
39
|
+
["questions", "asker"],
|
|
40
|
+
["questions", "updated_by"],
|
|
41
|
+
["questions", "answered_by"],
|
|
39
42
|
];
|
|
40
43
|
function actorOwnsRows(actorId) {
|
|
41
44
|
return ACTOR_OWNER_COLUMNS.some(([table, column]) => existsWhere(table, column, actorId));
|
|
@@ -139,7 +142,7 @@ export function registerMeta(server) {
|
|
|
139
142
|
inputSchema: { project_id: idParam },
|
|
140
143
|
}, ({ project_id }) => run(() => selectProjectById(project_id)));
|
|
141
144
|
server.registerTool("project_prune", {
|
|
142
|
-
description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust), verified individually before each delete. Never prunes the caller's own project. With project_id, removes exactly that one project instead of sweeping, and only if it owns no rows; otherwise refuses and names what it owns. With project_id AND confirm_name (the project's exact name), removes that one project even when it owns rows: it takes a snapshot first, refuses while a running row owns a live pane or its pane ownership cannot be verified (agent_close row_only retires the latter), then deletes the project with everything it owns (todos, comments, pads, kv, leases, agents, wakes, trust, agent messages) in one transaction and reports per-table counts and the snapshot path. queen_audit rows naming it are kept, and so are other projects' lead-idle subscriptions that target it, so a watch on its lead ends with a named reason. confirm_name without project_id is refused, and there is no forced sweep. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
|
|
145
|
+
description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust, questions), verified individually before each delete. Never prunes the caller's own project. With project_id, removes exactly that one project instead of sweeping, and only if it owns no rows; otherwise refuses and names what it owns. With project_id AND confirm_name (the project's exact name), removes that one project even when it owns rows: it takes a snapshot first, refuses while a running row owns a live pane or its pane ownership cannot be verified (agent_close row_only retires the latter), then deletes the project with everything it owns (todos, comments, pads, kv, leases, agents, wakes, trust, questions, agent messages) in one transaction and reports per-table counts and the snapshot path. queen_audit rows naming it are kept, and so are other projects' lead-idle subscriptions that target it, so a watch on its lead ends with a named reason. confirm_name without project_id is refused, and there is no forced sweep. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
|
|
143
146
|
annotations: {
|
|
144
147
|
readOnlyHint: false,
|
|
145
148
|
destructiveHint: true,
|