@cmgmyr/hive 1.11.3 → 1.11.4
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/dist/abi.js +9 -7
- package/dist/build-info.json +1 -1
- package/dist/cli.js +10 -6
- package/dist/dispatcher.js +4 -1
- package/dist/help.js +20 -14
- package/dist/kvValue.js +8 -0
- package/dist/scheduler.js +77 -37
- package/dist/tools/agents.js +11 -5
- package/dist/tools/kv.js +6 -5
- package/package.json +1 -1
package/dist/abi.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import { cliPath, dispatcherPath, readDispatcher } from "./dispatcher.js";
|
|
4
|
+
import { cliPath, dispatcherPath, readDispatcher, repinCommand } from "./dispatcher.js";
|
|
5
5
|
const requireFromHere = createRequire(import.meta.url);
|
|
6
6
|
export function classifyAddonLoadError(error) {
|
|
7
7
|
return /NODE_MODULE_VERSION \d+|Module did not self-register/i.test(error) ? "mismatch" : "load";
|
|
@@ -129,7 +129,7 @@ export function abiFixLines(status, pinned) {
|
|
|
129
129
|
if (status.failure === "napi") {
|
|
130
130
|
const need = status.nodeApiRequired;
|
|
131
131
|
const range = need === null ? null : nodeRangeForNodeApi(need);
|
|
132
|
-
const good = pinned
|
|
132
|
+
const good = pinned ?? `<a Node matching ${range ?? "that Node-API level"}>`;
|
|
133
133
|
return [
|
|
134
134
|
`better-sqlite3's addon is built against Node-API ${need}${range ? `, which only Node ${range} provides` : ""}.`,
|
|
135
135
|
`This interpreter provides Node-API ${describeNodeApi(status.nodeApi)}, so the addon cannot load here.`,
|
|
@@ -141,7 +141,7 @@ export function abiFixLines(status, pinned) {
|
|
|
141
141
|
"",
|
|
142
142
|
`Run hive under a Node matching ${range ?? "that Node-API level"}, and re-pin the \`hive\` command`,
|
|
143
143
|
"to it (setup pins whatever Node runs it, so it has to be that one):",
|
|
144
|
-
` ${good
|
|
144
|
+
` ${repinCommand(good, cliPath())}`,
|
|
145
145
|
];
|
|
146
146
|
}
|
|
147
147
|
if (status.failure === "missing") {
|
|
@@ -164,7 +164,7 @@ export function abiFixLines(status, pinned) {
|
|
|
164
164
|
if (status.failure !== "mismatch") {
|
|
165
165
|
return ["Build it: npm install && npm run build"];
|
|
166
166
|
}
|
|
167
|
-
const good = pinned
|
|
167
|
+
const good = pinned ?? "<the Node that built it>";
|
|
168
168
|
return [
|
|
169
169
|
"better-sqlite3's addon is ABI-locked to the Node that compiled it, and a Node",
|
|
170
170
|
"version manager resolves `node` from the working directory, so a `cd` is enough",
|
|
@@ -172,10 +172,12 @@ export function abiFixLines(status, pinned) {
|
|
|
172
172
|
"",
|
|
173
173
|
"Run hive under the Node it was built for, and re-pin the `hive` command to it",
|
|
174
174
|
"(setup pins whatever Node runs it, so it has to be that one):",
|
|
175
|
-
` ${good
|
|
175
|
+
` ${repinCommand(good, cliPath())}`,
|
|
176
176
|
"",
|
|
177
|
-
"
|
|
178
|
-
"
|
|
177
|
+
"Rebuilding is not the fix for the supported addon. Update hive, then re-pin this CLI:",
|
|
178
|
+
" Global install: npm install -g @cmgmyr/hive",
|
|
179
|
+
" Checkout: npm install && npm run build",
|
|
180
|
+
` ${repinCommand(good, cliPath())}`,
|
|
179
181
|
];
|
|
180
182
|
}
|
|
181
183
|
function workingInterpreterFromDispatcher() {
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.11.
|
|
1
|
+
{"version":"1.11.4","sha":"be85c43","dirty":false,"build_id":"002d81642b9604f453a66605e3bc23656ad7b425069e016be8bfda87d6e532d1"}
|
package/dist/cli.js
CHANGED
|
@@ -3,14 +3,14 @@ import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
5
5
|
import { homedir, tmpdir } from "node:os";
|
|
6
|
-
import { join, resolve, sep } from "node:path";
|
|
6
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
7
7
|
import { createInterface } from "node:readline/promises";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
9
9
|
import { checkAbi, describeAbi, describeInterpreter, nodeRangeForNodeApi, requiredNodeApi } from "./abi.js";
|
|
10
10
|
import { versionInfo } from "./version.js";
|
|
11
11
|
import { claudeConfigDir } from "./claudeDir.js";
|
|
12
12
|
import { ATTACH_MODES, AUTO_ATTACH_MODES, attachMode, isAttachMode, isAutoAttach, migrateLegacyConfig, resolvedAutoAttach, setAttachMode, setAutoAttach, } from "./config.js";
|
|
13
|
-
import { cliPath, dispatcherDir, dispatcherPath, dispatcherScript, durabilityLines, firstHiveOnPath, linkedWorktreePin, pathAdvice, readDispatcher, } from "./dispatcher.js";
|
|
13
|
+
import { cliPath, dispatcherDir, dispatcherPath, dispatcherScript, durabilityLines, firstHiveOnPath, linkedWorktreePin, pathAdvice, repinCommand, readDispatcher, } from "./dispatcher.js";
|
|
14
14
|
import { codexConfigPath, codexHiveRegistrations, codexRegistrationProblem, hiveRegistrations, registrationOffer, registrationProblem, } from "./mcpConfig.js";
|
|
15
15
|
import { DEFAULT_DATA_DIR, storeDir } from "./dataDir.js";
|
|
16
16
|
import { dataDir, db, migrate, storeSchemaAhead } from "./db.js";
|
|
@@ -1889,7 +1889,7 @@ function reportDispatcher() {
|
|
|
1889
1889
|
}
|
|
1890
1890
|
info("dispatcher", `${dispatcher.file} -> ${dispatcher.node ?? "an exec line hive cannot parse"}`);
|
|
1891
1891
|
if (!dispatcher.node || !dispatcher.cli) {
|
|
1892
|
-
gatingWarn("dispatcher", "written by another version of hive; re-
|
|
1892
|
+
gatingWarn("dispatcher", "written by another version of hive; use this re-pin command to refresh it:", ` ${repinCommand(process.execPath, cliPath())}`);
|
|
1893
1893
|
}
|
|
1894
1894
|
else if (!existsSync(dispatcher.node)) {
|
|
1895
1895
|
const need = requiredNodeApi();
|
|
@@ -1907,7 +1907,7 @@ function reportDispatcher() {
|
|
|
1907
1907
|
gatingWarn("dispatcher", `pinned to a linked git worktree, which this repo tears down: ${dispatcher.cli}`, "A worktree teardown leaves `hive` failing with MODULE_NOT_FOUND, and running `hive setup`", "to repair it just re-pins the same worktree, since that shim is what runs the command.", `Re-pin from the durable checkout instead: "${dispatcher.node}" "${repairCli}" setup`);
|
|
1908
1908
|
}
|
|
1909
1909
|
else if (dispatcher.node !== process.execPath || dispatcher.cli !== cliPath()) {
|
|
1910
|
-
gatingWarn("dispatcher", "pinned to a different build than this CLI is running.", `this run: ${process.execPath} ${cliPath()}`,
|
|
1910
|
+
gatingWarn("dispatcher", "pinned to a different build than this CLI is running.", `this run: ${process.execPath} ${cliPath()}`, `Re-pin after a rebuild: ${repinCommand(process.execPath, cliPath())}`);
|
|
1911
1911
|
}
|
|
1912
1912
|
if (onPath !== dispatcher.file)
|
|
1913
1913
|
gatingWarn("dispatcher", ...pathAdvice(dispatcherDir(), dispatcher.file));
|
|
@@ -2005,8 +2005,12 @@ function reportPtyHeadroom(orphans) {
|
|
|
2005
2005
|
const scratchHeld = orphans ? orphans.live + orphans.wedged : 0;
|
|
2006
2006
|
const livePanes = db.prepare("SELECT COUNT(*) AS n FROM agents WHERE status = 'running'").get()
|
|
2007
2007
|
.n;
|
|
2008
|
-
warn("ptys", `${headroom.allocated} of ${headroom.max} in use (${free} free), below the safety margin${overrideNote}`, shellsLine, `${scratchHeld} orphaned scratch tmux server(s) still holding a socket (detail in the scratch tmux servers check)`, `${livePanes} pane(s) recorded 'running' in hive's own store (leads and workers, this machine)`, "
|
|
2009
|
-
|
|
2008
|
+
warn("ptys", `${headroom.allocated} of ${headroom.max} in use (${free} free), below the safety margin${overrideNote}`, shellsLine, `${scratchHeld} orphaned scratch tmux server(s) still holding a socket (detail in the scratch tmux servers check)`, `${livePanes} pane(s) recorded 'running' in hive's own store (leads and workers, this machine)`, ...(existsSync(join(dirname(cliPath()), "..", "scripts", "sweep-scratch.mjs"))
|
|
2009
|
+
? [
|
|
2010
|
+
"reap the orphaned shells and scratch servers with `node scripts/sweep-scratch.mjs` (dry run by default, " +
|
|
2011
|
+
"`--kill` to act)",
|
|
2012
|
+
]
|
|
2013
|
+
: []));
|
|
2010
2014
|
}
|
|
2011
2015
|
function reportOrphanTmuxServers(orphans) {
|
|
2012
2016
|
if (!orphans)
|
package/dist/dispatcher.js
CHANGED
|
@@ -10,6 +10,9 @@ export const cliPath = () => fileURLToPath(new URL("./cli.js", import.meta.url))
|
|
|
10
10
|
function shQuote(s) {
|
|
11
11
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
12
12
|
}
|
|
13
|
+
export function repinCommand(node, cli) {
|
|
14
|
+
return `${shQuote(node)} ${shQuote(cli)} setup`;
|
|
15
|
+
}
|
|
13
16
|
export function dispatcherScript(node, cli) {
|
|
14
17
|
return [
|
|
15
18
|
"#!/bin/sh",
|
|
@@ -17,7 +20,7 @@ export function dispatcherScript(node, cli) {
|
|
|
17
20
|
"# Written by `hive setup`. It runs hive under one fixed interpreter, so a",
|
|
18
21
|
"# version manager resolving `node` per directory cannot land hive on a Node",
|
|
19
22
|
"# better-sqlite3's addon refuses to load under.",
|
|
20
|
-
|
|
23
|
+
`# Regenerate after every update: ${repinCommand(node, cli)}`,
|
|
21
24
|
`exec ${shQuote(node)} ${shQuote(cli)} "$@"`,
|
|
22
25
|
"",
|
|
23
26
|
].join("\n");
|
package/dist/help.js
CHANGED
|
@@ -7,10 +7,10 @@ one project-scoped state store: pads, todos, kv, leases.
|
|
|
7
7
|
Call help(topic="<name>") for details. Topics: ${Object.keys(HELP_TOPICS).join(", ")}.
|
|
8
8
|
|
|
9
9
|
Scope resolution for project-scoped tools:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
resolution order is project_id override, session selection, worker pin,
|
|
11
|
+
then working directory; a git worktree or subdirectory resolves to its
|
|
12
|
+
primary checkout, and an unmatched directory becomes a new project
|
|
13
|
+
instead of falling back to an unrelated one
|
|
14
14
|
|
|
15
15
|
Quick start:
|
|
16
16
|
1. whoami — see your actor id and effective project
|
|
@@ -46,6 +46,9 @@ QUESTIONS — open questions for the human, recorded in the store
|
|
|
46
46
|
PROJECTS — scope management
|
|
47
47
|
project_list, project_add, project_select, project_prune
|
|
48
48
|
|
|
49
|
+
QUEEN AUDIT — confirmed queen writes and refused cross-project guard calls
|
|
50
|
+
queen_audit_list(project_id?, limit?)
|
|
51
|
+
|
|
49
52
|
ACTORS — who is writing
|
|
50
53
|
actor_prune`;
|
|
51
54
|
}
|
|
@@ -131,7 +134,7 @@ If the human asks to change how work runs here, that is an edit to the
|
|
|
131
134
|
runbook (fork it first), not a pad write.`,
|
|
132
135
|
agents: `AGENTS — spawn and drive worker sessions in tmux
|
|
133
136
|
|
|
134
|
-
agent_spawn(name?, model?, command?, extra_args?, read_only?, cwd?, placement?, layout?) —
|
|
137
|
+
agent_spawn(name?, model?, command?, extra_args?, harness?, read_only?, cwd?, placement?, layout?, todo_id?) —
|
|
135
138
|
start a worker (default command: claude) in session hive-main.
|
|
136
139
|
placement="split" (default) tiles the worker as a pane in the lead's
|
|
137
140
|
window so the whole crew shares one screen; placement="window" gives it
|
|
@@ -169,7 +172,7 @@ runbook (fork it first), not a pad write.`,
|
|
|
169
172
|
stays agent:N, so older pad writes and todo comments still point here.
|
|
170
173
|
A live claude worker is told to retitle its own session, which arrives as
|
|
171
174
|
a user turn: rename between assignments, not mid-task.
|
|
172
|
-
agent_park(name|agent_id) — END OF DAY. Kill the pane, mark the row PARKED
|
|
175
|
+
agent_park(name|agent_id, confirm_self?) — END OF DAY. Kill the pane, mark the row PARKED
|
|
173
176
|
rather than plain closed, record the branch, and hand back a board line
|
|
174
177
|
plus the one call that brings the lane back. Use this instead of
|
|
175
178
|
agent_close whenever the lane is PAUSED rather than finished: "closed"
|
|
@@ -179,7 +182,7 @@ runbook (fork it first), not a pad write.`,
|
|
|
179
182
|
with the same actor_id and its full prior context. It does not send
|
|
180
183
|
the assignment; agent_send it afterwards. Read the pane before
|
|
181
184
|
believing any wake about a worker you just resumed.
|
|
182
|
-
agent_close(name|agent_id) — kill the worker's pane and mark closed. Capture
|
|
185
|
+
agent_close(name|agent_id, confirm_self?, row_only?) — kill the worker's pane and mark closed. Capture
|
|
183
186
|
handoffs first; output is not retained. Self-close needs confirm_self.
|
|
184
187
|
On a PARKED row (by agent_id) it releases the park instead, which is how
|
|
185
188
|
you abandon a lane you have decided not to resume. row_only=true marks
|
|
@@ -239,8 +242,9 @@ its turn), waiting (needs permission or input). Non-claude commands show
|
|
|
239
242
|
interval used for firings after this one. Only body can be edited on an
|
|
240
243
|
idle wake (from wake_when_idle); delay_seconds/repeat_every_seconds
|
|
241
244
|
apply to a delay wake (from wake_set) only.
|
|
242
|
-
wake_cancel(wake_id) — cancel a pending wake-up you own
|
|
243
|
-
|
|
245
|
+
wake_cancel(wake_id) — cancel a pending wake-up you own, or any pending
|
|
246
|
+
wake-up in this project when you are a running lead
|
|
247
|
+
wake_list() — pending wake-ups and recently delivered one-shot wakes
|
|
244
248
|
|
|
245
249
|
Delivery contract:
|
|
246
250
|
When a wake-up fires, its body is typed into the target session's terminal
|
|
@@ -338,7 +342,7 @@ place.`,
|
|
|
338
342
|
pad_append(pad_id, content, expected_revision?) — add to the end
|
|
339
343
|
pad_edit(pad_id, old_text, new_text, expected_revision?) — replace one
|
|
340
344
|
unique literal occurrence; include enough context to disambiguate
|
|
341
|
-
pad_list(query?, tags?, include_archived?) — summaries without content;
|
|
345
|
+
pad_list(query?, tags?, include_archived?, limit?, offset?) — summaries without content;
|
|
342
346
|
query matches names and content and returns a snippet
|
|
343
347
|
pad_archive(pad_id, archived?) — archive frees the name for a new active
|
|
344
348
|
pad; content stays readable by pad_id. archived=false unarchives.
|
|
@@ -401,10 +405,12 @@ a phase flag. Use pads for anything longer than a line or two.`,
|
|
|
401
405
|
to a pending or defaulted question
|
|
402
406
|
|
|
403
407
|
hive stores the question; it never puts it to the human. Render it in your
|
|
404
|
-
own turn. Answering queues a wake to the asker carrying the question,
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
+
own turn. Answering queues a wake to the asker carrying the question, answer
|
|
409
|
+
and notes, or reroutes it to the project's running lead when the asker has
|
|
410
|
+
no running row. It skips wake delivery when you are the asker, no eligible
|
|
411
|
+
target is running, or the target cannot be resolved. The answer stays
|
|
412
|
+
readable either way. Nothing expires or defaults on its own: only the asker
|
|
413
|
+
or the human moves a question out of pending.`,
|
|
408
414
|
leases: `LEASES — advisory expiring claims on shared state
|
|
409
415
|
|
|
410
416
|
lease_acquire(key, ttl_seconds) — non-blocking; returns the holder if
|
package/dist/kvValue.js
ADDED
package/dist/scheduler.js
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, lstatSync, mkdirSync, realpathSync, renameSync, statSync, u
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { dirname, join, sep } from "node:path";
|
|
6
6
|
import { dataDir, db, storeReplaced } from "./db.js";
|
|
7
|
+
import { decodeKvValue } from "./kvValue.js";
|
|
7
8
|
import { maybeBackupHourly } from "./backup.js";
|
|
8
9
|
import { renderDashboardForWrite } from "./dashboard.js";
|
|
9
10
|
import { snapshotProcesses } from "./processes.js";
|
|
@@ -183,11 +184,11 @@ function reportDeadProcess(agent, snapshot) {
|
|
|
183
184
|
// interrupted between the two is not reported as a crash.
|
|
184
185
|
if (stoppingMarkerLive(agent.project_id, agent.id))
|
|
185
186
|
return;
|
|
186
|
-
const lead = stmt(`SELECT actor_id, tmux_target, tmux_socket, pane_pid FROM agents
|
|
187
|
+
const lead = stmt(`SELECT actor_id, tmux_target, tmux_socket, pane_pid, command FROM agents
|
|
187
188
|
WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id DESC LIMIT 1`).get(agent.project_id, LEAD_KIND);
|
|
188
189
|
// Never mint a notice hive could not deliver: with no live lead pane there is nobody this could
|
|
189
190
|
// ever reach, and the row would sit in wakes forever.
|
|
190
|
-
if (!lead?.tmux_target || rowOwnership(lead, snapshot) !== "live")
|
|
191
|
+
if (!lead?.tmux_target || !screenClassifiable(lead.command) || rowOwnership(lead, snapshot) !== "live")
|
|
191
192
|
return;
|
|
192
193
|
insertNotice({ project_id: agent.project_id, owner: lead.actor_id }, lead.actor_id, lead.tmux_target, deadProcessBody(agent.name), null);
|
|
193
194
|
}
|
|
@@ -410,19 +411,22 @@ function renderQueenPage(queenId) {
|
|
|
410
411
|
const QUEEN_PAGE_CLAIM_KEY = "hive:queen-page-claim";
|
|
411
412
|
const QUEEN_PAGE_MARK_KEY = "hive:queen-page-mark";
|
|
412
413
|
function claimQueenPageAttempt(queenId) {
|
|
413
|
-
bestEffortRun("INSERT OR IGNORE INTO kv (project_id, key, value, updated_at) VALUES (?, ?,
|
|
414
|
+
bestEffortRun("INSERT OR IGNORE INTO kv (project_id, key, value, updated_at) VALUES (?, ?, ?, '1970-01-01 00:00:00')", queenId, QUEEN_PAGE_CLAIM_KEY, JSON.stringify(""));
|
|
414
415
|
return (stmt(`UPDATE kv SET updated_at = datetime('now')
|
|
415
416
|
WHERE project_id = ? AND key = ?
|
|
416
417
|
AND updated_at <= datetime('now', '-${DASHBOARD_MIN_INTERVAL_SECONDS} seconds')`).run(queenId, QUEEN_PAGE_CLAIM_KEY).changes === 1);
|
|
417
418
|
}
|
|
418
419
|
function readQueenPageMark(queenId) {
|
|
419
420
|
const row = stmt("SELECT value FROM kv WHERE project_id = ? AND key = ?").get(queenId, QUEEN_PAGE_MARK_KEY);
|
|
420
|
-
|
|
421
|
+
if (!row)
|
|
422
|
+
return null;
|
|
423
|
+
const value = decodeKvValue(row.value);
|
|
424
|
+
return typeof value === "string" ? value : row.value;
|
|
421
425
|
}
|
|
422
426
|
function publishQueenPage(queenId, target, page) {
|
|
423
427
|
writeDashboardAtomically(target, page.html);
|
|
424
428
|
bestEffortRun(`INSERT INTO kv (project_id, key, value) VALUES (?, ?, ?)
|
|
425
|
-
ON CONFLICT(project_id, key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, queenId, QUEEN_PAGE_MARK_KEY, page.hash);
|
|
429
|
+
ON CONFLICT(project_id, key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, queenId, QUEEN_PAGE_MARK_KEY, JSON.stringify(page.hash));
|
|
426
430
|
}
|
|
427
431
|
export function generateQueenDashboardNow() {
|
|
428
432
|
try {
|
|
@@ -494,6 +498,24 @@ function driveOwnLeadHandoff() {
|
|
|
494
498
|
catch {
|
|
495
499
|
}
|
|
496
500
|
}
|
|
501
|
+
function isolated(label, task) {
|
|
502
|
+
const report = (err) => {
|
|
503
|
+
try {
|
|
504
|
+
console.error(`[scheduler] ${label} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
try {
|
|
510
|
+
const result = task();
|
|
511
|
+
if (result !== null && typeof result === "object" && "then" in result) {
|
|
512
|
+
return Promise.resolve(result).then(() => { }, report);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
report(err);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
497
519
|
export async function tick(snapshot) {
|
|
498
520
|
if (ticking)
|
|
499
521
|
return;
|
|
@@ -506,14 +528,15 @@ export async function tick(snapshot) {
|
|
|
506
528
|
}
|
|
507
529
|
if (snapshot === undefined)
|
|
508
530
|
snapshot = liveTargets();
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
531
|
+
const tickSnapshot = snapshot;
|
|
532
|
+
isolated("janitor", () => janitor(tickSnapshot));
|
|
533
|
+
if (tickSnapshot)
|
|
534
|
+
isolated("running-build check", () => reportRunningBuildChange(tickSnapshot));
|
|
535
|
+
isolated("lead handoff", driveOwnLeadHandoff);
|
|
536
|
+
isolated("wake confirmations", checkConfirmations);
|
|
537
|
+
isolated("state-log pruning", pruneStateLog);
|
|
538
|
+
isolated("hourly backup", () => maybeBackupHourly(db, dataDir));
|
|
539
|
+
isolated("dashboard generation", maybeGenerateDashboards);
|
|
517
540
|
const now = stmt("SELECT datetime('now') AS now").get().now;
|
|
518
541
|
const candidates = stmt(`SELECT wakes.*, ${DELIVER_ROW_COLUMNS}
|
|
519
542
|
FROM wakes ${DELIVER_SOCKET_JOIN}
|
|
@@ -524,18 +547,20 @@ export async function tick(snapshot) {
|
|
|
524
547
|
)`).all();
|
|
525
548
|
const choices = new Map();
|
|
526
549
|
for (const timer of candidates) {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
550
|
+
await isolated(`timer ${timer.id}`, async () => {
|
|
551
|
+
if (timer.kind === "delay") {
|
|
552
|
+
await fireDelay(timer, tickSnapshot, choices);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const leadWatch = leadSubscription(timer.id);
|
|
556
|
+
if (leadWatch !== null)
|
|
557
|
+
await maybeFireLeadWatch(timer, leadWatch, tickSnapshot, now, choices);
|
|
558
|
+
else
|
|
559
|
+
await maybeFireIdle(timer, tickSnapshot, now, choices);
|
|
560
|
+
});
|
|
536
561
|
}
|
|
537
|
-
await retryUnconfirmedSocketWakes(
|
|
538
|
-
await retryQuietLeadMessages(
|
|
562
|
+
await isolated("unconfirmed socket wake retry", () => retryUnconfirmedSocketWakes(tickSnapshot, choices));
|
|
563
|
+
await isolated("quiet lead message retry", () => retryQuietLeadMessages(tickSnapshot));
|
|
539
564
|
}
|
|
540
565
|
catch {
|
|
541
566
|
}
|
|
@@ -972,6 +997,8 @@ function alreadyToldAbout(timerId, agentId, blockedSince) {
|
|
|
972
997
|
const CONDITION_IDLE = "idle";
|
|
973
998
|
const CONDITION_GONE = "gone";
|
|
974
999
|
const NOTICE_RETRY_AFTER = "-60 seconds";
|
|
1000
|
+
const spentUntypedNotice = (alias) => `${alias}.fired_at IS NOT NULL AND COALESCE(${alias}.typed_at, ${alias}.socket_attempt_at) IS NULL ` +
|
|
1001
|
+
`AND ${alias}.cancelled_at IS NULL AND ${alias}.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}')`;
|
|
975
1002
|
const NOTICE_MAX_AGE = "-1 hours";
|
|
976
1003
|
const ROSTER_STILL_GOING = 8;
|
|
977
1004
|
const FINISHED_SHOWN_CAP = ROSTER_STILL_GOING;
|
|
@@ -979,8 +1006,7 @@ const STANDING_EXPIRED_NOTE = "this standing watch has expired and nothing is wa
|
|
|
979
1006
|
const unreported = (condition, episode) => `NOT EXISTS (
|
|
980
1007
|
SELECT 1 FROM wake_idle_notices n LEFT JOIN wakes nt ON nt.id = n.notice_wake_id
|
|
981
1008
|
WHERE n.wake_id = ? AND n.agent_id = a.id AND n.condition = '${condition}' AND n.episode = ${episode}
|
|
982
|
-
AND NOT (
|
|
983
|
-
AND nt.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}')))`;
|
|
1009
|
+
AND NOT (${spentUntypedNotice("nt")}))`;
|
|
984
1010
|
const CREW_COLUMNS = `a.id, a.name, a.actor_id, a.tmux_target, a.tmux_socket, a.pane_pid, a.agent_state,
|
|
985
1011
|
a.state_changed_at, a.status, a.command, a.kind, a.resumed_at, a.closed_at,
|
|
986
1012
|
a.cwd, a.session_id, a.transcript_path`;
|
|
@@ -1218,12 +1244,11 @@ function standingNoticeBody(timer, finished, totalFinished, carriedTotal, span)
|
|
|
1218
1244
|
return lines.join("\n");
|
|
1219
1245
|
}
|
|
1220
1246
|
function rearmSpentEpisode(timerId, agentId, condition, episode) {
|
|
1221
|
-
stmt(`DELETE FROM wake_idle_notices
|
|
1247
|
+
return stmt(`DELETE FROM wake_idle_notices
|
|
1222
1248
|
WHERE wake_id = ? AND agent_id = ? AND condition = ? AND episode = ?
|
|
1223
1249
|
AND notice_wake_id IS NOT NULL
|
|
1224
1250
|
AND EXISTS (SELECT 1 FROM wakes t WHERE t.id = wake_idle_notices.notice_wake_id
|
|
1225
|
-
AND t.
|
|
1226
|
-
AND t.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}'))`).run(timerId, agentId, condition, episode);
|
|
1251
|
+
AND ${spentUntypedNotice("t")})`).run(timerId, agentId, condition, episode).changes > 0;
|
|
1227
1252
|
}
|
|
1228
1253
|
function claimEpisode(timerId, agentId, condition, episode) {
|
|
1229
1254
|
return (stmt(`INSERT OR IGNORE INTO wake_idle_notices (wake_id, agent_id, condition, episode)
|
|
@@ -1366,11 +1391,15 @@ const claimStallBatch = db.transaction((timer, tell, candidates, observedAt) =>
|
|
|
1366
1391
|
return false;
|
|
1367
1392
|
const won = [];
|
|
1368
1393
|
for (const c of candidates) {
|
|
1369
|
-
|
|
1394
|
+
const rearmed = rearmSpentEpisode(timer.id, c.row.id, CONDITION_STALL, c.row.episode);
|
|
1395
|
+
if (!claimEpisode(timer.id, c.row.id, CONDITION_STALL, c.row.episode))
|
|
1370
1396
|
continue;
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1397
|
+
if (c.row.agent_state === "waiting" && !rearmed && !claimBlockNotice(timer.id, c.row.id, c.row.episode)) {
|
|
1398
|
+
stmt(`DELETE FROM wake_idle_notices
|
|
1399
|
+
WHERE wake_id = ? AND agent_id = ? AND condition = ? AND episode = ? AND notice_wake_id IS NULL`).run(timer.id, c.row.id, CONDITION_STALL, c.row.episode);
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
won.push(c);
|
|
1374
1403
|
}
|
|
1375
1404
|
if (won.length === 0)
|
|
1376
1405
|
return false;
|
|
@@ -2164,10 +2193,21 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
|
|
|
2164
2193
|
return;
|
|
2165
2194
|
if (firing !== null && !pasted)
|
|
2166
2195
|
dropRepeatingFiring(timer, firing, err);
|
|
2167
|
-
if (
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2196
|
+
if (err instanceof PaneOwnershipLost) {
|
|
2197
|
+
if (firing === null || pasted) {
|
|
2198
|
+
recordLostAfterClaim(timer, pasted, err instanceof Error ? err.message : String(err), firing);
|
|
2199
|
+
}
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
if (pasted) {
|
|
2203
|
+
const repairableNotice = stmt(`SELECT 1 FROM wake_idle_notices n JOIN wakes w ON w.id = n.notice_wake_id
|
|
2204
|
+
WHERE n.notice_wake_id = ? AND w.fired_at IS NOT NULL
|
|
2205
|
+
AND COALESCE(w.typed_at, w.socket_attempt_at) IS NULL AND w.cancelled_at IS NULL
|
|
2206
|
+
LIMIT 1`).get(timer.id);
|
|
2207
|
+
if (pasted && repairableNotice === undefined) {
|
|
2208
|
+
recordLostAfterClaim(timer, pasted, err instanceof Error ? err.message : String(err), firing);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2171
2211
|
return;
|
|
2172
2212
|
}
|
|
2173
2213
|
finally {
|
package/dist/tools/agents.js
CHANGED
|
@@ -996,6 +996,7 @@ export function registerAgents(server) {
|
|
|
996
996
|
const classifier = renameHarness.paneClassifier;
|
|
997
997
|
const { awaitingChoice, tail } = classifier.choiceCheck(agent.tmux_target);
|
|
998
998
|
const box = awaitingChoice === false ? classifier.inputBoxState(agent.tmux_target) : null;
|
|
999
|
+
const inCopyMode = paneInCopyMode(agent.tmux_target);
|
|
999
1000
|
if (awaitingChoice === true) {
|
|
1000
1001
|
heldNote =
|
|
1001
1002
|
"Not retitled: the pane is waiting on a choice, so typing /rename would answer it instead of setting the title. Clear the prompt first (agent_send with keys), then retry.";
|
|
@@ -1017,6 +1018,11 @@ export function registerAgents(server) {
|
|
|
1017
1018
|
`Not retitled: the pane's input box holds unsubmitted text, so /rename would be pasted onto the end of it and submitted as prose rather than run as a command. The row IS renamed - it is "${newName}" now - and only the pane's own title was left alone. Clear the line with agent_send(keys: ["C-a", "C-k"]) once you can attribute the text, then call agent_rename(name: "${newName}", new_name: "${newName}") to retitle the pane.`;
|
|
1018
1019
|
heldTail = tail;
|
|
1019
1020
|
}
|
|
1021
|
+
else if (inCopyMode === true) {
|
|
1022
|
+
heldNote =
|
|
1023
|
+
`Not retitled: the pane is in tmux copy mode, so /rename would not reach its prompt. The row IS renamed - it is "${newName}" now - and only the pane's own title was left alone. Leave copy mode (press q or Escape there), then call agent_rename(name: "${newName}", new_name: "${newName}") to retitle the pane.`;
|
|
1024
|
+
heldTail = tail;
|
|
1025
|
+
}
|
|
1020
1026
|
else {
|
|
1021
1027
|
let pasted = false;
|
|
1022
1028
|
try {
|
|
@@ -1191,6 +1197,7 @@ export function registerAgents(server) {
|
|
|
1191
1197
|
const agent = resolveQueenLeadTarget("agent_send", project.id, args.agent_id ?? args.name, () => findAgent(project.id, args));
|
|
1192
1198
|
requireLive(agent);
|
|
1193
1199
|
const target = agent.tmux_target;
|
|
1200
|
+
const identity = paneIdentity(agent);
|
|
1194
1201
|
const auditIdentity = captureQueenWriteIdentity(project.id);
|
|
1195
1202
|
let outgoing = args.text ?? "";
|
|
1196
1203
|
const tag = senderTag(project.id, currentActor());
|
|
@@ -1241,10 +1248,9 @@ export function registerAgents(server) {
|
|
|
1241
1248
|
}
|
|
1242
1249
|
if (args.wait_ms == null)
|
|
1243
1250
|
return quiet;
|
|
1244
|
-
await sleep(
|
|
1251
|
+
await sleep(args.wait_ms);
|
|
1245
1252
|
try {
|
|
1246
|
-
|
|
1247
|
-
throw new Error("not live");
|
|
1253
|
+
requireStillOwned(agent.id, identity, "agent_send");
|
|
1248
1254
|
return { ...quiet, tail: capturePane(target, 15) };
|
|
1249
1255
|
}
|
|
1250
1256
|
catch {
|
|
@@ -1342,7 +1348,6 @@ export function registerAgents(server) {
|
|
|
1342
1348
|
outgoing = tag + args.text;
|
|
1343
1349
|
let pasted = false;
|
|
1344
1350
|
let buffered = false;
|
|
1345
|
-
const identity = paneIdentity(agent);
|
|
1346
1351
|
try {
|
|
1347
1352
|
await sendText(target, outgoing, submitting, () => {
|
|
1348
1353
|
pasted = true;
|
|
@@ -1376,9 +1381,10 @@ export function registerAgents(server) {
|
|
|
1376
1381
|
throw new Error("Pass text or keys.");
|
|
1377
1382
|
}
|
|
1378
1383
|
if (args.wait_ms != null) {
|
|
1379
|
-
await sleep(
|
|
1384
|
+
await sleep(args.wait_ms);
|
|
1380
1385
|
let tailField;
|
|
1381
1386
|
try {
|
|
1387
|
+
requireStillOwned(agent.id, identity, "agent_send");
|
|
1382
1388
|
tailField = { tail: capturePane(target, 15) };
|
|
1383
1389
|
}
|
|
1384
1390
|
catch {
|
package/dist/tools/kv.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { db } from "../db.js";
|
|
3
3
|
import { currentActor, effectiveProjectId } from "../context.js";
|
|
4
4
|
import { run } from "../result.js";
|
|
5
|
+
import { decodeKvValue } from "../kvValue.js";
|
|
5
6
|
import { idParam, projectIdParam } from "./params.js";
|
|
6
7
|
function purgeExpired(projectId) {
|
|
7
8
|
db.prepare("DELETE FROM kv WHERE project_id = ? AND expires_at IS NOT NULL AND expires_at < datetime('now')").run(projectId);
|
|
@@ -56,7 +57,7 @@ export function registerKv(server) {
|
|
|
56
57
|
project_id: projectId,
|
|
57
58
|
key: row.key,
|
|
58
59
|
found: true,
|
|
59
|
-
value:
|
|
60
|
+
value: decodeKvValue(row.value),
|
|
60
61
|
updated_by: row.updated_by,
|
|
61
62
|
updated_at: row.updated_at,
|
|
62
63
|
expires_at: row.expires_at,
|
|
@@ -76,9 +77,9 @@ export function registerKv(server) {
|
|
|
76
77
|
purgeExpired(projectId);
|
|
77
78
|
let sql = "SELECT * FROM kv WHERE project_id = ?";
|
|
78
79
|
const params = [projectId];
|
|
79
|
-
if (args.prefix) {
|
|
80
|
-
sql += " AND key
|
|
81
|
-
params.push(
|
|
80
|
+
if (args.prefix !== undefined) {
|
|
81
|
+
sql += " AND substr(key, 1, length(?)) = ?";
|
|
82
|
+
params.push(args.prefix, args.prefix);
|
|
82
83
|
}
|
|
83
84
|
sql += " ORDER BY key";
|
|
84
85
|
const rows = db.prepare(sql).all(...params);
|
|
@@ -86,7 +87,7 @@ export function registerKv(server) {
|
|
|
86
87
|
project_id: projectId,
|
|
87
88
|
entries: rows.map((r) => ({
|
|
88
89
|
key: r.key,
|
|
89
|
-
value:
|
|
90
|
+
value: decodeKvValue(r.value),
|
|
90
91
|
updated_by: r.updated_by,
|
|
91
92
|
updated_at: r.updated_at,
|
|
92
93
|
expires_at: r.expires_at,
|
package/package.json
CHANGED