@cmgmyr/hive 1.11.9 → 1.12.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/dist/agentResume.js +222 -0
- package/dist/build-info.json +1 -1
- package/dist/cli.js +265 -2
- package/dist/codexHome.js +105 -1
- package/dist/context.js +1 -0
- package/dist/db.js +25 -0
- package/dist/help.js +39 -5
- package/dist/hook.js +61 -5
- package/dist/hooks.js +1 -0
- package/dist/leadMessageDelivery.js +77 -3
- package/dist/projectYml.js +30 -2
- package/dist/scheduler.js +165 -27
- package/dist/spawn.js +35 -39
- package/dist/todoCompletionGate.js +124 -0
- package/dist/tools/agents.js +449 -131
- package/dist/tools/todos.js +84 -9
- package/dist/usageLimit.js +470 -0
- package/dist/wakeSql.js +2 -0
- package/dist/workerRecovery.js +16 -0
- package/package.json +1 -1
package/dist/scheduler.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { QUEEN_AUDIT_RETENTION, QUEEN_AUDIT_MAX_ROWS, recentQueenAudit } from "./queenAudit.js";
|
|
2
|
-
import { ACTIVE_TIMER_WHERE, DELIVERY_METHOD, FIRING_DROPPED_NOTE, HELD_REASON_ACTOR_CLOSED, HELD_REASON_CONVERSATION, HELD_REASON_COPY_MODE, HELD_REASON_HANDOFF, HELD_REASON_LEAD_BOOT_INPUT_BOX, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_LEAD_ROW_CLOSED, HELD_REASON_MODAL_CHOICE, HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM, HELD_REASON_PANE_REISSUED_LEAD, HELD_REASON_PANE_REISSUED_WORKER, HELD_REASON_PANE_UNKNOWN_LEAD, HELD_REASON_PANE_UNKNOWN_WORKER, HELD_REASON_UNCLASSIFIABLE_PANE, HELD_REASON_UNSUBMITTED_INPUT, HELD_REASON_UNSUBMITTED_INPUT_OWNER_UNRESOLVED, LOG_RETENTION, activeWakeWhere, holdableWakeWhere, identityThenDead, pendingOneShotWhere, repeatingFiringPendingWhere, repeatingSocketAwaitingWhere, repeatingSocketFiringWhere, schedulerCandidateWhere, sentWakeAtSql, socketAwaitingWhere, spentUntypedNotice, wasHeldForPaneIdentity, watchWatchingWhere, } from "./wakeSql.js";
|
|
2
|
+
import { ACTIVE_TIMER_WHERE, DELIVERY_METHOD, FIRING_DROPPED_NOTE, HELD_REASON_ACTOR_CLOSED, HELD_REASON_CONVERSATION, HELD_REASON_COPY_MODE, HELD_REASON_HANDOFF, HELD_REASON_LEAD_BOOT_INPUT_BOX, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_LEAD_ROW_CLOSED, HELD_REASON_MODAL_CHOICE, HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM, HELD_REASON_PANE_CAPTURE_UNKNOWN, HELD_REASON_PANE_REISSUED_LEAD, HELD_REASON_PANE_REISSUED_WORKER, HELD_REASON_PANE_UNKNOWN_LEAD, HELD_REASON_PANE_UNKNOWN_WORKER, HELD_REASON_UNCLASSIFIABLE_PANE, HELD_REASON_UNSUBMITTED_INPUT, HELD_REASON_UNSUBMITTED_INPUT_OWNER_UNRESOLVED, HELD_REASON_USAGE_LIMIT, LOG_RETENTION, activeWakeWhere, holdableWakeWhere, identityThenDead, pendingOneShotWhere, repeatingFiringPendingWhere, repeatingSocketAwaitingWhere, repeatingSocketFiringWhere, schedulerCandidateWhere, sentWakeAtSql, socketAwaitingWhere, spentUntypedNotice, wasHeldForPaneIdentity, watchWatchingWhere, } from "./wakeSql.js";
|
|
3
3
|
export { ACTIVE_TIMER_WHERE, DELIVERY_METHOD, HELD_REASON_ACTOR_CLOSED, HELD_REASON_CONVERSATION, HELD_REASON_COPY_MODE, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_UNCLASSIFIABLE_PANE, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, HELD_REASON_UNSUBMITTED_INPUT, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, isLeadRowClosedHold, isUnclassifiablePaneHold, isUnsubmittedInputHold, socketAwaitingWhere, wasHeldForPaneIdentity, wasHeldForPaneReissue, } from "./wakeSql.js";
|
|
4
4
|
import { parseStoreTimestamp } from "./storeClock.js";
|
|
5
5
|
import { runningBuildChange, runningBuildNotice, versionInfo } from "./version.js";
|
|
@@ -8,6 +8,7 @@ import { createHash } from "node:crypto";
|
|
|
8
8
|
import { dirname, join, sep } from "node:path";
|
|
9
9
|
import { dataDir, db, storeReplaced } from "./db.js";
|
|
10
10
|
import { decodeKvValue } from "./kvValue.js";
|
|
11
|
+
import { continueLimitedCodexWorkers, observeUsageLimit, usageLimitGate } from "./usageLimit.js";
|
|
11
12
|
import { maybeBackupHourly } from "./backup.js";
|
|
12
13
|
import { renderDashboardForWrite } from "./dashboard.js";
|
|
13
14
|
import { snapshotProcesses } from "./processes.js";
|
|
@@ -28,6 +29,7 @@ import { driveLeadHandoff } from "./leadHandoff.js";
|
|
|
28
29
|
import { handoffHoldsWake } from "./leadHandoffState.js";
|
|
29
30
|
import { FIRST_MESSAGE_LOG_STATE, humanPromptSql, readLeadTurnState } from "./leadState.js";
|
|
30
31
|
import { retryQuietLeadMessages } from "./leadMessageDelivery.js";
|
|
32
|
+
import { markWorkerLost, WORKER_RECOVERY_RETENTION_SECONDS } from "./workerRecovery.js";
|
|
31
33
|
import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
|
|
32
34
|
import { transcriptDir, readContextFill } from "./transcript.js";
|
|
33
35
|
import { ageSecondsSince, describeLastLogEvent, humanizeAge, lastLogEvent, reportsAgentStateLog, } from "./stateProvenance.js";
|
|
@@ -86,7 +88,7 @@ export function startScheduler(intervalMs = DEFAULT_SCHEDULER_INTERVAL_MS) {
|
|
|
86
88
|
}, intervalMs).unref();
|
|
87
89
|
}
|
|
88
90
|
const CREW_ROW_COLUMNS = "id, project_id, name, actor_id, kind, cwd, tmux_target, tmux_socket, pane_pid, session_id, agent_state, " +
|
|
89
|
-
"state_changed_at, created_at";
|
|
91
|
+
"state_changed_at, created_at, recovery_state";
|
|
90
92
|
// Set only from a tick that SAW panes, so it separates "hive watched this socket die" from "hive
|
|
91
93
|
// arrived after the fact" - the two bounds the record has to tell apart.
|
|
92
94
|
let socketLastSeenAlive = null;
|
|
@@ -164,11 +166,13 @@ function recordTeardown(snapshot, swept) {
|
|
|
164
166
|
function reapClosedCodexHomes() {
|
|
165
167
|
let reaped = 0;
|
|
166
168
|
try {
|
|
167
|
-
const rows = stmt(
|
|
169
|
+
const rows = stmt(`SELECT id, codex_home FROM agents
|
|
170
|
+
WHERE codex_home != '' AND status = 'closed' AND parked_at = ''
|
|
171
|
+
AND (recovery_state != 'pending' OR closed_at <= datetime('now', ?))`).all(`-${WORKER_RECOVERY_RETENTION_SECONDS} seconds`);
|
|
168
172
|
for (const row of rows) {
|
|
169
173
|
try {
|
|
170
|
-
reapCodexHomeForClosedAgent(row.id, row.codex_home)
|
|
171
|
-
|
|
174
|
+
if (reapCodexHomeForClosedAgent(row.id, row.codex_home) !== null)
|
|
175
|
+
reaped += 1;
|
|
172
176
|
}
|
|
173
177
|
catch {
|
|
174
178
|
}
|
|
@@ -235,7 +239,19 @@ export function janitor(snapshot = liveTargets()) {
|
|
|
235
239
|
if (probe.live === false || paneReissued(agent.pane_pid, probe)) {
|
|
236
240
|
// The conditional UPDATE is the claim: two instances probing the same dead socket both reach
|
|
237
241
|
// this branch, and only the one that actually closed the row may put it in a record.
|
|
238
|
-
|
|
242
|
+
const identity = {
|
|
243
|
+
tmux_target: agent.tmux_target,
|
|
244
|
+
tmux_socket: agent.tmux_socket,
|
|
245
|
+
pane_pid: agent.pane_pid,
|
|
246
|
+
};
|
|
247
|
+
const closed = agent.kind === "agent"
|
|
248
|
+
? agent.recovery_state === "excluded"
|
|
249
|
+
? closeAgentRow(agent.id, identity)
|
|
250
|
+
: snapshot?.serverAnswered === true
|
|
251
|
+
? markWorkerLost(agent.id, identity)
|
|
252
|
+
: closeAgentRow(agent.id, identity)
|
|
253
|
+
: closeAgentRow(agent.id, identity);
|
|
254
|
+
if (closed) {
|
|
239
255
|
closedAgents += 1;
|
|
240
256
|
swept.push(agent);
|
|
241
257
|
}
|
|
@@ -542,11 +558,18 @@ export async function tick(snapshot) {
|
|
|
542
558
|
isolated("hourly backup", () => maybeBackupHourly(db, dataDir));
|
|
543
559
|
isolated("dashboard generation", maybeGenerateDashboards);
|
|
544
560
|
const now = stmt("SELECT datetime('now') AS now").get().now;
|
|
561
|
+
await isolated("Codex usage-limit continuation", () => continueLimitedCodexWorkers(Date.now()));
|
|
545
562
|
const candidates = stmt(`SELECT wakes.*, ${DELIVER_ROW_COLUMNS}
|
|
546
563
|
FROM wakes ${DELIVER_SOCKET_JOIN}
|
|
547
|
-
WHERE ${schedulerCandidateWhere("wakes.")}
|
|
564
|
+
WHERE ${schedulerCandidateWhere("wakes.")}
|
|
565
|
+
ORDER BY wakes.id`).all();
|
|
566
|
+
const orderedDelays = candidates
|
|
567
|
+
.filter((timer) => timer.kind === "delay")
|
|
568
|
+
.sort((a, b) => (a.due_at ?? "").localeCompare(b.due_at ?? "") || a.id - b.id);
|
|
569
|
+
let delayIndex = 0;
|
|
548
570
|
const choices = new Map();
|
|
549
|
-
for (const
|
|
571
|
+
for (const candidate of candidates) {
|
|
572
|
+
const timer = candidate.kind === "delay" ? orderedDelays[delayIndex++] : candidate;
|
|
550
573
|
await isolated(`timer ${timer.id}`, async () => {
|
|
551
574
|
if (timer.kind === "delay") {
|
|
552
575
|
await fireDelay(timer, tickSnapshot, choices);
|
|
@@ -622,6 +645,35 @@ function holdTimer(timer, reason) {
|
|
|
622
645
|
first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
|
|
623
646
|
WHERE id = ? AND ${holdableWakeWhere()}`, reason, timer.id, timer.due_at, count, count);
|
|
624
647
|
}
|
|
648
|
+
function usageLimitHeld(timer) {
|
|
649
|
+
if (timer.deliver_row_id === null)
|
|
650
|
+
return false;
|
|
651
|
+
const now = Date.now();
|
|
652
|
+
observeUsageLimit(timer.deliver_row_id, now);
|
|
653
|
+
const gate = usageLimitGate(timer.deliver_row_id, now);
|
|
654
|
+
if (!gate.held)
|
|
655
|
+
return false;
|
|
656
|
+
holdTimer(timer, `${HELD_REASON_USAGE_LIMIT}${gate.until ?? "the recorded deadline"}`);
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
class UsageLimitRace extends Error {
|
|
660
|
+
until;
|
|
661
|
+
constructor(until) {
|
|
662
|
+
super(`${HELD_REASON_USAGE_LIMIT}${until}`);
|
|
663
|
+
this.until = until;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function unclaimUsageLimit(timer, until) {
|
|
667
|
+
try {
|
|
668
|
+
stmt(`UPDATE wakes SET due_at = ?, fired_at = NULL, fire_count = MAX(0, fire_count - 1),
|
|
669
|
+
typed_at = NULL, typed_busy = NULL, typed_seen = NULL, confirmed_at = NULL,
|
|
670
|
+
held_at = datetime('now'), held_reason = ?, first_held_at = COALESCE(first_held_at, datetime('now')),
|
|
671
|
+
socket_attempt_at = NULL, socket_delivery_note = NULL, delivery_method = NULL, delivered_by = NULL
|
|
672
|
+
WHERE id = ? AND fire_count = ? AND typed_at IS NULL AND cancelled_at IS NULL`)
|
|
673
|
+
.run(timer.due_at, `${HELD_REASON_USAGE_LIMIT}${until}`, timer.id, timer.fire_count);
|
|
674
|
+
}
|
|
675
|
+
catch { }
|
|
676
|
+
}
|
|
625
677
|
const LEAD_BOOT_BOX_WAIT_MS = 60_000;
|
|
626
678
|
function leadBootSessionIsFresh(agentId, panePid) {
|
|
627
679
|
const row = stmt("SELECT pane_started_at FROM agents WHERE id = ? AND pane_pid = ?").get(agentId, panePid);
|
|
@@ -927,6 +979,7 @@ function alreadyToldAbout(timerId, agentId, blockedSince) {
|
|
|
927
979
|
const CONDITION_IDLE = "idle";
|
|
928
980
|
const CONDITION_GONE = "gone";
|
|
929
981
|
const NOTICE_MAX_AGE = "-1 hours";
|
|
982
|
+
const PANE_CAPTURE_UNKNOWN_MAX_AGE = "-1 hours";
|
|
930
983
|
const ROSTER_STILL_GOING = 8;
|
|
931
984
|
const FINISHED_SHOWN_CAP = ROSTER_STILL_GOING;
|
|
932
985
|
const STANDING_EXPIRED_NOTE = "this standing watch has expired and nothing is watching now; set a new one if the crew is still working";
|
|
@@ -1528,7 +1581,12 @@ function deliverable(timer, snapshot, choices) {
|
|
|
1528
1581
|
holdTimer(timer, HELD_REASON_UNCLASSIFIABLE_PANE);
|
|
1529
1582
|
return { ok: false };
|
|
1530
1583
|
}
|
|
1584
|
+
const releasedAfterCaptureHold = timer.held_reason === paneCaptureReleaseReason(timer.fire_count + 1);
|
|
1531
1585
|
const dialog = awaitingChoice(timer.deliver_pane, timer.deliver_command, choices);
|
|
1586
|
+
if (dialog === null && !releasedAfterCaptureHold && paneCaptureUnknownHoldApplies(timer)) {
|
|
1587
|
+
holdTimer(timer, HELD_REASON_PANE_CAPTURE_UNKNOWN);
|
|
1588
|
+
return { ok: false };
|
|
1589
|
+
}
|
|
1532
1590
|
if (dialog === true) {
|
|
1533
1591
|
noteModalHold(timer, snapshot);
|
|
1534
1592
|
return { ok: false };
|
|
@@ -1579,7 +1637,7 @@ function deliverable(timer, snapshot, choices) {
|
|
|
1579
1637
|
const box = boxState === undefined ? "unknown" : boxState === null ? "absent" : boxState.state;
|
|
1580
1638
|
return {
|
|
1581
1639
|
ok: true,
|
|
1582
|
-
typedSeen: `live=yes pid=${pid} dialog=${dialogSeen} box=${box}`,
|
|
1640
|
+
typedSeen: `live=yes pid=${pid} dialog=${dialogSeen} box=${box}${releasedAfterCaptureHold ? "; released after unreadable-capture hold" : ""}`,
|
|
1583
1641
|
firstHeldAt: timer.held_at != null ? timer.first_held_at : null,
|
|
1584
1642
|
owner,
|
|
1585
1643
|
};
|
|
@@ -1613,6 +1671,8 @@ async function fireDelay(timer, snapshot, choices) {
|
|
|
1613
1671
|
holdTimer(timer, HELD_REASON_HANDOFF);
|
|
1614
1672
|
return;
|
|
1615
1673
|
}
|
|
1674
|
+
if (usageLimitHeld(timer))
|
|
1675
|
+
return;
|
|
1616
1676
|
const disposition = noticeDisposition(timer);
|
|
1617
1677
|
if (disposition === "aged") {
|
|
1618
1678
|
try {
|
|
@@ -1628,7 +1688,37 @@ async function fireDelay(timer, snapshot, choices) {
|
|
|
1628
1688
|
}
|
|
1629
1689
|
if (timer.repeat_every_ms != null && repeatingFiringPending(timer.id))
|
|
1630
1690
|
return;
|
|
1631
|
-
|
|
1691
|
+
const releaseReason = releaseExpiredPaneCaptureHold(timer);
|
|
1692
|
+
const deliveryTimer = releaseReason === null ? timer : { ...timer, held_reason: releaseReason };
|
|
1693
|
+
await withPaneClaim(deliveryTimer, () => fireDelayClaimed(deliveryTimer, snapshot, choices));
|
|
1694
|
+
}
|
|
1695
|
+
function paneCaptureReleaseReason(firing) {
|
|
1696
|
+
return `released after unreadable-capture hold at the one-hour cap for firing #${firing}`;
|
|
1697
|
+
}
|
|
1698
|
+
function paneCaptureUnknownHoldApplies(timer) {
|
|
1699
|
+
if (timer.deliver_row_id === null)
|
|
1700
|
+
return false;
|
|
1701
|
+
return stmt(`SELECT 1 AS hit FROM agent_usage_limits
|
|
1702
|
+
WHERE agent_id = ? AND (
|
|
1703
|
+
(released_at IS NULL AND expires_at >= datetime('now', '-1 hour'))
|
|
1704
|
+
OR released_at >= datetime('now', '-1 hour')
|
|
1705
|
+
)`).get(timer.deliver_row_id) !== undefined;
|
|
1706
|
+
}
|
|
1707
|
+
function releaseExpiredPaneCaptureHold(timer) {
|
|
1708
|
+
if (timer.held_reason !== HELD_REASON_PANE_CAPTURE_UNKNOWN || timer.first_held_at === null)
|
|
1709
|
+
return null;
|
|
1710
|
+
try {
|
|
1711
|
+
const age = stmt("SELECT ? <= datetime('now', ?) AS expired").get(timer.first_held_at, PANE_CAPTURE_UNKNOWN_MAX_AGE);
|
|
1712
|
+
if (age.expired !== 1)
|
|
1713
|
+
return null;
|
|
1714
|
+
const reason = paneCaptureReleaseReason(timer.fire_count + 1);
|
|
1715
|
+
const released = stmt(`UPDATE wakes SET held_reason = ?
|
|
1716
|
+
WHERE id = ? AND held_reason = ? AND first_held_at IS ? AND ${holdableWakeWhere()}`).run(reason, timer.id, HELD_REASON_PANE_CAPTURE_UNKNOWN, timer.first_held_at, timer.due_at, timer.fire_count, timer.fire_count);
|
|
1717
|
+
return released.changes === 1 ? reason : null;
|
|
1718
|
+
}
|
|
1719
|
+
catch {
|
|
1720
|
+
return null;
|
|
1721
|
+
}
|
|
1632
1722
|
}
|
|
1633
1723
|
async function fireDelayClaimed(timer, snapshot, choices) {
|
|
1634
1724
|
const decision = deliverable(timer, snapshot, choices);
|
|
@@ -1644,21 +1734,34 @@ async function fireDelayClaimed(timer, snapshot, choices) {
|
|
|
1644
1734
|
socket_attempt_at = NULL, socket_delivery_note = NULL, delivery_method = NULL, delivered_by = NULL
|
|
1645
1735
|
WHERE id = ? AND due_at IS ? AND body IS ? AND repeat_every_ms IS ? AND cancelled_at IS NULL
|
|
1646
1736
|
AND fire_count = ? AND NOT COALESCE((${REPEATING_FIRING_PENDING}), 0)
|
|
1737
|
+
AND NOT EXISTS (SELECT 1 FROM agent_usage_limits u WHERE u.agent_id =
|
|
1738
|
+
(SELECT a.id FROM agents a WHERE a.actor_id = wakes.deliver_actor
|
|
1739
|
+
ORDER BY (a.status = 'running') DESC, a.id DESC LIMIT 1)
|
|
1740
|
+
AND u.released_at IS NULL AND u.expires_at > datetime('now'))
|
|
1647
1741
|
RETURNING fire_count, fired_at`).get(seconds, timer.id, timer.due_at, timer.body, timer.repeat_every_ms, timer.fire_count);
|
|
1648
1742
|
if (claimed === undefined)
|
|
1649
1743
|
return false;
|
|
1650
1744
|
firing = { ...timer, fire_count: claimed.fire_count, fired_at: claimed.fired_at, socket_attempt_at: null, socket_delivery_note: null, delivery_method: null };
|
|
1651
1745
|
}
|
|
1652
|
-
else
|
|
1653
|
-
|
|
1746
|
+
else {
|
|
1747
|
+
const claimed = claimOneShot(timer);
|
|
1748
|
+
if (!claimed)
|
|
1749
|
+
return false;
|
|
1750
|
+
firing = { ...timer, ...claimed };
|
|
1654
1751
|
}
|
|
1655
1752
|
await deliver(firing, "", choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
|
|
1656
1753
|
return true;
|
|
1657
1754
|
}
|
|
1658
1755
|
function claimOneShot(timer) {
|
|
1659
|
-
|
|
1756
|
+
const claimed = stmt(`UPDATE wakes SET fired_at = datetime('now'), fire_count = fire_count + 1
|
|
1660
1757
|
WHERE id = ? AND due_at IS ? AND body IS ? AND repeat_every_ms IS ?
|
|
1661
|
-
AND ${pendingOneShotWhere()}
|
|
1758
|
+
AND ${pendingOneShotWhere()}
|
|
1759
|
+
AND NOT EXISTS (SELECT 1 FROM agent_usage_limits u WHERE u.agent_id =
|
|
1760
|
+
(SELECT a.id FROM agents a WHERE a.actor_id = wakes.deliver_actor
|
|
1761
|
+
ORDER BY (a.status = 'running') DESC, a.id DESC LIMIT 1)
|
|
1762
|
+
AND u.released_at IS NULL AND u.expires_at > datetime('now'))
|
|
1763
|
+
RETURNING fire_count, fired_at`).get(timer.id, timer.due_at, timer.body, timer.repeat_every_ms);
|
|
1764
|
+
return claimed ?? null;
|
|
1662
1765
|
}
|
|
1663
1766
|
const GONE = { idle: true, gone: true, since: null };
|
|
1664
1767
|
const UNKNOWN = { idle: false, gone: false, since: null };
|
|
@@ -1712,6 +1815,8 @@ function idleCeilingStart(timer, snapshot) {
|
|
|
1712
1815
|
return timer.held_at != null ? timer.first_held_at : null;
|
|
1713
1816
|
}
|
|
1714
1817
|
async function maybeFireIdle(timer, snapshot, now, choices) {
|
|
1818
|
+
if (usageLimitHeld(timer))
|
|
1819
|
+
return;
|
|
1715
1820
|
const timedOut = timer.max_wait_at != null && timer.max_wait_at <= now;
|
|
1716
1821
|
if (isStandingWatch(timer)) {
|
|
1717
1822
|
noteStandingTransitions(timer, snapshot);
|
|
@@ -1721,9 +1826,12 @@ async function maybeFireIdle(timer, snapshot, now, choices) {
|
|
|
1721
1826
|
if (timedOut) {
|
|
1722
1827
|
await withPaneClaim(timer, async () => {
|
|
1723
1828
|
const decision = deliverable(timer, snapshot, choices);
|
|
1724
|
-
if (!decision.ok
|
|
1829
|
+
if (!decision.ok)
|
|
1725
1830
|
return false;
|
|
1726
|
-
|
|
1831
|
+
const claimed = claimOneShot(timer);
|
|
1832
|
+
if (!claimed)
|
|
1833
|
+
return false;
|
|
1834
|
+
await deliver({ ...timer, ...claimed }, STANDING_EXPIRED_NOTE, choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
|
|
1727
1835
|
return true;
|
|
1728
1836
|
});
|
|
1729
1837
|
}
|
|
@@ -1747,9 +1855,12 @@ async function maybeFireIdle(timer, snapshot, now, choices) {
|
|
|
1747
1855
|
if (ready) {
|
|
1748
1856
|
await withPaneClaim(timer, async () => {
|
|
1749
1857
|
const decision = deliverable(timer, snapshot, choices);
|
|
1750
|
-
if (!decision.ok
|
|
1858
|
+
if (!decision.ok)
|
|
1859
|
+
return false;
|
|
1860
|
+
const claimed = claimOneShot(timer);
|
|
1861
|
+
if (!claimed)
|
|
1751
1862
|
return false;
|
|
1752
|
-
await deliver(timer, timedOut && !idleMet ? "max wait reached" : "", choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
|
|
1863
|
+
await deliver({ ...timer, ...claimed }, timedOut && !idleMet ? "max wait reached" : "", choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
|
|
1753
1864
|
return true;
|
|
1754
1865
|
});
|
|
1755
1866
|
}
|
|
@@ -1757,6 +1868,8 @@ async function maybeFireIdle(timer, snapshot, now, choices) {
|
|
|
1757
1868
|
// A lead watch never enters the worker helpers below: it reads its own subscription row and the
|
|
1758
1869
|
// watched lead's own turn record, and ends with a named reason rather than ever reading as idle.
|
|
1759
1870
|
async function maybeFireLeadWatch(timer, sub, snapshot, now, choices) {
|
|
1871
|
+
if (usageLimitHeld(timer))
|
|
1872
|
+
return;
|
|
1760
1873
|
const timedOut = timer.max_wait_at != null && timer.max_wait_at <= now;
|
|
1761
1874
|
const readers = {
|
|
1762
1875
|
awaitingChoice: (pane, command) => awaitingChoice(pane, command, choices),
|
|
@@ -1774,12 +1887,15 @@ async function maybeFireLeadWatch(timer, sub, snapshot, now, choices) {
|
|
|
1774
1887
|
return;
|
|
1775
1888
|
await withPaneClaim(timer, async () => {
|
|
1776
1889
|
const decision = deliverable(timer, snapshot, choices);
|
|
1777
|
-
if (!decision.ok
|
|
1890
|
+
if (!decision.ok)
|
|
1891
|
+
return false;
|
|
1892
|
+
const claimed = claimOneShot(timer);
|
|
1893
|
+
if (!claimed)
|
|
1778
1894
|
return false;
|
|
1779
1895
|
if (result.kind === "invalid") {
|
|
1780
1896
|
bestEffortRun("UPDATE lead_idle_subscriptions SET terminal_reason = ? WHERE wake_id = ?", result.reason, timer.id);
|
|
1781
1897
|
}
|
|
1782
|
-
await deliver(timer, note, choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
|
|
1898
|
+
await deliver({ ...timer, ...claimed }, note, choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
|
|
1783
1899
|
return true;
|
|
1784
1900
|
});
|
|
1785
1901
|
}
|
|
@@ -1966,11 +2082,11 @@ function socketStillRegistered(timer, owner) {
|
|
|
1966
2082
|
row.pid === row.pane_pid);
|
|
1967
2083
|
}
|
|
1968
2084
|
// A failed stamp means the firing was cancelled or superseded, so nothing is posted.
|
|
1969
|
-
function stampRepeatingAttempt(timer, note, firstHeldAt) {
|
|
2085
|
+
function stampRepeatingAttempt(timer, note, typedSeen, firstHeldAt) {
|
|
1970
2086
|
try {
|
|
1971
2087
|
return (stmt(`UPDATE wakes SET socket_attempt_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), socket_delivery_note = ?,
|
|
1972
|
-
delivery_method = ?, delivered_by = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
|
|
1973
|
-
WHERE id = ? AND fire_count = ? AND cancelled_at IS NULL AND typed_at IS NULL AND socket_attempt_at IS NULL`).run(note, DELIVERY_METHOD.socketRepeating, deliveredByForFiring(timer), firstHeldAt, timer.id, timer.fire_count).changes === 1);
|
|
2088
|
+
delivery_method = ?, delivered_by = ?, first_held_at = ?, typed_seen = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
|
|
2089
|
+
WHERE id = ? AND fire_count = ? AND cancelled_at IS NULL AND typed_at IS NULL AND socket_attempt_at IS NULL`).run(note, DELIVERY_METHOD.socketRepeating, deliveredByForFiring(timer), firstHeldAt, typedSeen, timer.id, timer.fire_count).changes === 1);
|
|
1974
2090
|
}
|
|
1975
2091
|
catch {
|
|
1976
2092
|
return false;
|
|
@@ -2013,25 +2129,31 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
|
|
|
2013
2129
|
const text = `${head}${note ? `, ${note}` : ""}] ${body + staleness + tail}\n\n${SOCKET_WAKE_FOOTER}`;
|
|
2014
2130
|
if (!text.includes(CROSS_SESSION_CLOSE)) {
|
|
2015
2131
|
if (repeating) {
|
|
2016
|
-
if (!stampRepeatingAttempt(timer, note, firstHeldAt))
|
|
2132
|
+
if (!stampRepeatingAttempt(timer, note, typedSeen, firstHeldAt))
|
|
2017
2133
|
return;
|
|
2018
2134
|
firing = timer.fire_count;
|
|
2019
2135
|
}
|
|
2020
2136
|
else {
|
|
2021
2137
|
bestEffortRun(`UPDATE wakes SET socket_attempt_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), socket_delivery_note = ?,
|
|
2022
|
-
delivery_method = ?, delivered_by = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
|
|
2023
|
-
WHERE id = ?`, note, DELIVERY_METHOD.socket, deliveredByForFiring(timer), firstHeldAt, timer.id);
|
|
2138
|
+
delivery_method = ?, delivered_by = ?, first_held_at = ?, typed_seen = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
|
|
2139
|
+
WHERE id = ?`, note, DELIVERY_METHOD.socket, deliveredByForFiring(timer), firstHeldAt, typedSeen, timer.id);
|
|
2024
2140
|
}
|
|
2025
2141
|
const posted = await postClaudeWake({
|
|
2026
2142
|
socketPath: timer.deliver_messaging_socket,
|
|
2027
2143
|
senderAddress: socketSenderAddress(dataDir),
|
|
2028
2144
|
text,
|
|
2029
|
-
beforeWrite: () => socketStillRegistered(timer, owner),
|
|
2145
|
+
beforeWrite: () => !usageLimitHeld(timer) && socketStillRegistered(timer, owner),
|
|
2030
2146
|
});
|
|
2031
2147
|
if (posted) {
|
|
2032
2148
|
forgetPaneAnswers(timer.deliver_pane, choices);
|
|
2033
2149
|
return;
|
|
2034
2150
|
}
|
|
2151
|
+
if (usageLimitHeld(timer)) {
|
|
2152
|
+
const until = timer.deliver_row_id === null ? null : usageLimitGate(timer.deliver_row_id, Date.now()).until;
|
|
2153
|
+
if (until)
|
|
2154
|
+
unclaimUsageLimit(timer, until);
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2035
2157
|
if (firing !== null && !claimRepeatingFallback(timer))
|
|
2036
2158
|
return;
|
|
2037
2159
|
note = withReDelivered(note);
|
|
@@ -2067,10 +2189,24 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
|
|
|
2067
2189
|
requireStillOwned(owner.rowId, owner.identity, `wake #${timer.id} delivery`);
|
|
2068
2190
|
if (firing !== null)
|
|
2069
2191
|
requireCurrentFiring(timer.id, firing);
|
|
2192
|
+
if (timer.deliver_row_id !== null) {
|
|
2193
|
+
observeUsageLimit(timer.deliver_row_id, Date.now());
|
|
2194
|
+
const gate = usageLimitGate(timer.deliver_row_id, Date.now());
|
|
2195
|
+
if (gate.held && gate.until)
|
|
2196
|
+
throw new UsageLimitRace(gate.until);
|
|
2197
|
+
}
|
|
2070
2198
|
}
|
|
2071
2199
|
: undefined);
|
|
2072
2200
|
}
|
|
2073
2201
|
catch (err) {
|
|
2202
|
+
if (err instanceof UsageLimitRace) {
|
|
2203
|
+
if (!pasted)
|
|
2204
|
+
unclaimUsageLimit(timer, err.until);
|
|
2205
|
+
else
|
|
2206
|
+
recordLostAfterClaim(timer, true, `${err.message} The body was pasted, and no Enter was sent.`, firing);
|
|
2207
|
+
forgetPaneAnswers(timer.deliver_pane, choices);
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2074
2210
|
if (fallback && !pasted && firing === null) {
|
|
2075
2211
|
bestEffortRun("UPDATE wakes SET delivery_method = ?, delivered_by = ? WHERE id = ? AND typed_at IS NULL", DELIVERY_METHOD.socket, deliveredByForFiring(timer), timer.id);
|
|
2076
2212
|
}
|
|
@@ -2125,6 +2261,8 @@ async function retryUnconfirmedSocketWakes(snapshot, choices) {
|
|
|
2125
2261
|
WHERE ((${socketAwaitingWhere("wakes.")}) OR (${repeatingSocketAwaitingWhere("wakes.")}))
|
|
2126
2262
|
AND wakes.socket_attempt_at >= datetime('now', ?)`).all(LOG_RETENTION);
|
|
2127
2263
|
for (const timer of awaiting) {
|
|
2264
|
+
if (usageLimitHeld(timer))
|
|
2265
|
+
continue;
|
|
2128
2266
|
if (!socketFallbackDue(timer))
|
|
2129
2267
|
continue;
|
|
2130
2268
|
await withPaneClaim(timer, () => fallbackClaimed(timer, snapshot, choices));
|
package/dist/spawn.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { preservedRolloutPath, reapCodexHome } from "./codexHome.js";
|
|
2
|
+
import { codexHomeDir, preservedRolloutPath, reapCodexHome } from "./codexHome.js";
|
|
3
3
|
import { getProject } from "./context.js";
|
|
4
4
|
import { LEAD_KIND, LEAD_NAME } from "./agentKinds.js";
|
|
5
5
|
import { gitOut } from "./git.js";
|
|
6
6
|
import { dataDir, db } from "./db.js";
|
|
7
7
|
import { loadProjectYml } from "./projectYml.js";
|
|
8
|
+
import { WORKER_RECOVERY_RETENTION_SECONDS } from "./workerRecovery.js";
|
|
8
9
|
import { applyLayout, claimInitialWindow, createWindow, DEFAULT_LAYOUT, ensureSession, crossServerRefusal, findProcessesWindow, findProjectWindow, isPaneTarget, paneProcessExited, paneWindow, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, retainOnExitArgs, rowLive, sessionName, setPaneTitle, setRemainOnExit, makeProcessesWindow, tmux, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, windowOwner, windowTitle, rowOwnership, } from "./tmux.js";
|
|
9
10
|
export function withWindowClaim(claim) {
|
|
10
11
|
if (db.inTransaction) {
|
|
@@ -187,11 +188,11 @@ export function launchAgent(spec) {
|
|
|
187
188
|
let info;
|
|
188
189
|
try {
|
|
189
190
|
info = db
|
|
190
|
-
.prepare("INSERT INTO agents (project_id, name, command, cwd, kind, parent_actor_id, tmux_socket, session_id, codex_home, model, extra_args, todo_id) " +
|
|
191
|
-
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
191
|
+
.prepare("INSERT INTO agents (project_id, name, command, cwd, kind, parent_actor_id, tmux_socket, session_id, codex_home, model, extra_args, todo_id, forked_from_agent_id, forked_from_session_id, resumed_at) " +
|
|
192
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? IS NOT NULL THEN datetime('now') ELSE '' END)")
|
|
192
193
|
.run(spec.projectId, spec.name, typeof spec.commandString === "string" ? spec.commandString : "", spec.cwd, spec.kind, spec.parentActor, socket, spec.sessionId ?? "", spec.codexHome ?? "", spec.model ?? null, spec.readOnly
|
|
193
194
|
? JSON.stringify({ args: spec.extraArgs ?? [], read_only: true })
|
|
194
|
-
: spec.extraArgs ? JSON.stringify(spec.extraArgs) : null, spec.todoId ?? null);
|
|
195
|
+
: spec.extraArgs ? JSON.stringify(spec.extraArgs) : null, spec.todoId ?? null, spec.forkedFromAgentId ?? null, spec.forkedFromSessionId ?? "", spec.forkedFromAgentId ?? null);
|
|
195
196
|
}
|
|
196
197
|
catch (e) {
|
|
197
198
|
throw asNameClash(e, spec.name);
|
|
@@ -242,17 +243,21 @@ export const RESUME_FLIP_COLUMNS = [
|
|
|
242
243
|
"parked_at",
|
|
243
244
|
"parked_branch",
|
|
244
245
|
"resumed_at",
|
|
246
|
+
"recovery_state",
|
|
245
247
|
"tmux_socket",
|
|
246
248
|
"command",
|
|
247
249
|
];
|
|
248
|
-
export function resumeFlipSql() {
|
|
250
|
+
export function resumeFlipSql(recoveryOnly = false) {
|
|
249
251
|
return ("UPDATE agents SET status = 'running', closed_at = NULL, tmux_target = '', pane_pid = '', " +
|
|
250
|
-
`agent_state = 'unknown', state_changed_at = NULL, ${PARK_STAMP_CLEARED}, ` +
|
|
252
|
+
`agent_state = 'unknown', state_changed_at = NULL, ${PARK_STAMP_CLEARED}, recovery_state = '', ` +
|
|
251
253
|
"resumed_at = datetime('now'), tmux_socket = ?, command = ? " +
|
|
252
|
-
|
|
254
|
+
`WHERE id = ? AND status = 'closed'${recoveryOnly ? " AND recovery_state = 'pending'" : ""}`);
|
|
253
255
|
}
|
|
254
256
|
function restoreFlippedRow(agentId, before) {
|
|
255
|
-
|
|
257
|
+
const assignments = RESUME_FLIP_COLUMNS.map((column) => column === "recovery_state"
|
|
258
|
+
? "recovery_state = CASE WHEN recovery_state = 'excluded' THEN 'excluded' ELSE ? END"
|
|
259
|
+
: `${column} = ?`).join(", ");
|
|
260
|
+
db.prepare(`UPDATE agents SET ${assignments} ` +
|
|
256
261
|
"WHERE id = ? AND status = 'running' AND tmux_target = ''").run(...RESUME_FLIP_COLUMNS.map((c) => before[c] ?? null), agentId);
|
|
257
262
|
}
|
|
258
263
|
export function resumeAgent(spec) {
|
|
@@ -261,7 +266,7 @@ export function resumeAgent(spec) {
|
|
|
261
266
|
const socket = tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR);
|
|
262
267
|
let flip;
|
|
263
268
|
const captureBeforeFlip = db.prepare(`SELECT ${RESUME_FLIP_COLUMNS.join(", ")} FROM agents WHERE id = ?`);
|
|
264
|
-
const flipStatement = db.prepare(resumeFlipSql());
|
|
269
|
+
const flipStatement = db.prepare(resumeFlipSql(spec.recoveryOnly));
|
|
265
270
|
try {
|
|
266
271
|
flip = db
|
|
267
272
|
.transaction(() => ({
|
|
@@ -352,7 +357,7 @@ export function branchAt(cwd) {
|
|
|
352
357
|
}
|
|
353
358
|
export function parkAgentRow(agentId, expected, branch) {
|
|
354
359
|
const park = "UPDATE agents SET status = 'closed', closed_at = datetime('now'), parked_at = datetime('now'), " +
|
|
355
|
-
"parked_branch =
|
|
360
|
+
"parked_branch = ?, recovery_state = 'excluded' WHERE id = ? AND status = 'running' AND tmux_target = ?";
|
|
356
361
|
const row = db
|
|
357
362
|
.prepare(`${park} AND tmux_socket = ? AND pane_pid = ? RETURNING parked_at`)
|
|
358
363
|
.get(branch, agentId, expected.tmux_target, expected.tmux_socket, expected.pane_pid);
|
|
@@ -400,36 +405,27 @@ export function releaseParkRow(agentId) {
|
|
|
400
405
|
.prepare(`UPDATE agents SET ${PARK_STAMP_CLEARED} WHERE id = ? AND status = 'closed' AND parked_at != ''`)
|
|
401
406
|
.run(agentId).changes > 0);
|
|
402
407
|
}
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
// is named explicitly rather than left to fall out of "closed" incidentally: codex can now be
|
|
406
|
-
// parked too (harnessFor("codex").supportsResume, todo 563), exactly as this predicate already
|
|
407
|
-
// assumed some non-claude harness eventually would - deriving the exclusion from harness capability
|
|
408
|
-
// instead would have made this reap query's correctness depend on a table this file does not own.
|
|
409
|
-
//
|
|
410
|
-
// No conditional-UPDATE claim guards this, unlike closeAgentRow's "the conditional UPDATE is the
|
|
411
|
-
// claim" - a deliberate difference, not a gap. That pattern earns its keep against an OBSERVABLE
|
|
412
|
-
// side effect: a wake fired twice types into a pane twice. A reap has none - two instances racing
|
|
413
|
-
// the same directory produce one deleted directory (rmSync's force:true swallows ENOENT even mid
|
|
414
|
-
// recursive delete, not just on the top-level path), and the loser's `codex_home` update just
|
|
415
|
-
// matches zero rows. Deleted twice is deleted once, so there is nothing here for a claim to arbitrate.
|
|
416
|
-
//
|
|
417
|
-
// rmSync runs before the clear, not after: if it throws (permissions, a mid-delete race), codex_home
|
|
418
|
-
// stays set and this row is picked up again - by the next agent_close call, or by the janitor's own
|
|
419
|
-
// backstop sweep - rather than the failure being silently swallowed by a claim that already fired.
|
|
420
|
-
// The column IS the retry token; that is the guard here, it is just not shaped like a CAS.
|
|
421
|
-
// Both callers wrap this, so a failure here costs a retry, never the close or the sweep it runs inside.
|
|
422
|
-
// The target comes from the row's own transcript_path, not from reapCodexHome's returned list, so
|
|
423
|
-
// a retry and a racing reap converge on the same file; the rewrite runs before the codex_home clear.
|
|
408
|
+
// The selection can go stale while agent_resume claims the row, so revalidate and reap
|
|
409
|
+
// under one SQLite writer claim before deleting the home.
|
|
424
410
|
export function reapCodexHomeForClosedAgent(agentId, codexHome) {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
411
|
+
return db.transaction(() => {
|
|
412
|
+
const row = db
|
|
413
|
+
.prepare(`SELECT transcript_path FROM agents
|
|
414
|
+
WHERE id = ? AND codex_home = ? AND status = 'closed' AND parked_at = ''
|
|
415
|
+
AND (recovery_state != 'pending' OR closed_at <= datetime('now', ?))`)
|
|
416
|
+
.get(agentId, codexHome, `-${WORKER_RECOVERY_RETENTION_SECONDS} seconds`);
|
|
417
|
+
if (!row)
|
|
418
|
+
return null;
|
|
419
|
+
const oldTranscriptPath = row.transcript_path ?? "";
|
|
420
|
+
const dest = oldTranscriptPath ? preservedRolloutPath(codexHome, oldTranscriptPath) : undefined;
|
|
421
|
+
const homeExisted = existsSync(codexHomeDir(codexHome));
|
|
422
|
+
reapCodexHome(codexHome);
|
|
423
|
+
if (dest && existsSync(dest)) {
|
|
424
|
+
db.prepare("UPDATE agents SET transcript_path = ? WHERE id = ? AND transcript_path = ?").run(dest, agentId, oldTranscriptPath);
|
|
425
|
+
}
|
|
426
|
+
db.prepare("UPDATE agents SET codex_home = '' WHERE id = ? AND codex_home = ?").run(agentId, codexHome);
|
|
427
|
+
return homeExisted;
|
|
428
|
+
}).immediate();
|
|
433
429
|
}
|
|
434
430
|
export function closeAgentRow(agentId, expected) {
|
|
435
431
|
const info = expected === undefined
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { isTrusted } from "./leadLaunch.js";
|
|
3
|
+
import { loadProjectYml } from "./projectYml.js";
|
|
4
|
+
const GATE_NAME = "hive:todo-completion-gate";
|
|
5
|
+
const TIMEOUT_MS = 30_000;
|
|
6
|
+
const OUTPUT_TAIL_BYTES = 4096;
|
|
7
|
+
const GATE_APPROVAL_MESSAGE = "completion gate in hive.yml is not approved yet; run `hive` in a terminal in this project to review it.";
|
|
8
|
+
export function completionGateTrust(argv) {
|
|
9
|
+
return {
|
|
10
|
+
name: GATE_NAME,
|
|
11
|
+
command: JSON.stringify(argv),
|
|
12
|
+
dir: null,
|
|
13
|
+
env: {},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function todoCompletionGateTrusted(projectId, argv) {
|
|
17
|
+
const trust = completionGateTrust(argv);
|
|
18
|
+
return isTrusted(projectId, trust.name, trust.command, trust.dir, trust.env);
|
|
19
|
+
}
|
|
20
|
+
export function assertValidTodoCompletionGate(input, message) {
|
|
21
|
+
if (message)
|
|
22
|
+
throw failure("invalid", input, null, null, Buffer.alloc(0), Buffer.alloc(0), message);
|
|
23
|
+
}
|
|
24
|
+
export function assertTodoCompletionGateCurrent(input, originalArgv) {
|
|
25
|
+
const current = loadProjectYml(input.projectPath);
|
|
26
|
+
if (current.completionGateError || !current.config?.completion_gate) {
|
|
27
|
+
throw failure("changed", input, null, null, Buffer.alloc(0), Buffer.alloc(0));
|
|
28
|
+
}
|
|
29
|
+
if (JSON.stringify(current.config.completion_gate) !== JSON.stringify(originalArgv)) {
|
|
30
|
+
throw failure("changed", input, null, null, Buffer.alloc(0), Buffer.alloc(0), GATE_APPROVAL_MESSAGE);
|
|
31
|
+
}
|
|
32
|
+
if (!todoCompletionGateTrusted(input.projectId, originalArgv)) {
|
|
33
|
+
throw failure("untrusted", input, null, null, Buffer.alloc(0), Buffer.alloc(0));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function failure(reason, input, code, signal, stdout, stderr, detail) {
|
|
37
|
+
const output = [
|
|
38
|
+
stdout.length ? `stdout tail:\n${stdout.toString("utf8")}` : "",
|
|
39
|
+
stderr.length ? `stderr tail:\n${stderr.toString("utf8")}` : "",
|
|
40
|
+
].filter(Boolean).join("\n");
|
|
41
|
+
return new Error(`[todo_completion_gate:${reason}] todo ${input.todoId}; exit ${code ?? "unavailable"}` +
|
|
42
|
+
`${signal ? `; signal ${signal}` : ""}${output ? `\n${output}` : ""}` +
|
|
43
|
+
(detail ? `\n${detail}` : "") +
|
|
44
|
+
(reason === "untrusted"
|
|
45
|
+
? `\n${GATE_APPROVAL_MESSAGE}`
|
|
46
|
+
: ""));
|
|
47
|
+
}
|
|
48
|
+
export async function runTodoCompletionGate(input) {
|
|
49
|
+
if (!todoCompletionGateTrusted(input.projectId, input.argv)) {
|
|
50
|
+
throw failure("untrusted", input, null, null, Buffer.alloc(0), Buffer.alloc(0));
|
|
51
|
+
}
|
|
52
|
+
const [file, ...args] = input.argv;
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
const stdout = [];
|
|
55
|
+
const stderr = [];
|
|
56
|
+
let stdoutBytes = 0;
|
|
57
|
+
let stderrBytes = 0;
|
|
58
|
+
let settled = false;
|
|
59
|
+
let timedOut = false;
|
|
60
|
+
const child = spawn(file, args, {
|
|
61
|
+
cwd: input.projectPath,
|
|
62
|
+
env: {
|
|
63
|
+
...process.env,
|
|
64
|
+
HIVE_TODO_ID: String(input.todoId),
|
|
65
|
+
HIVE_TODO_TITLE: input.title,
|
|
66
|
+
HIVE_PROJECT_ID: String(input.projectId),
|
|
67
|
+
HIVE_ACTOR_ID: input.actorId,
|
|
68
|
+
},
|
|
69
|
+
shell: false,
|
|
70
|
+
detached: process.platform !== "win32",
|
|
71
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
72
|
+
});
|
|
73
|
+
const appendTail = (chunks, chunk, current) => {
|
|
74
|
+
chunks.push(chunk);
|
|
75
|
+
let total = current + chunk.length;
|
|
76
|
+
while (total > OUTPUT_TAIL_BYTES && chunks.length) {
|
|
77
|
+
const excess = total - OUTPUT_TAIL_BYTES;
|
|
78
|
+
if (chunks[0].length <= excess) {
|
|
79
|
+
total -= chunks.shift().length;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
chunks[0] = chunks[0].subarray(excess);
|
|
83
|
+
total -= excess;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return total;
|
|
87
|
+
};
|
|
88
|
+
child.stdout.on("data", (chunk) => { stdoutBytes = appendTail(stdout, chunk, stdoutBytes); });
|
|
89
|
+
child.stderr.on("data", (chunk) => { stderrBytes = appendTail(stderr, chunk, stderrBytes); });
|
|
90
|
+
const finish = (error) => {
|
|
91
|
+
if (settled)
|
|
92
|
+
return;
|
|
93
|
+
settled = true;
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
if (error)
|
|
96
|
+
reject(error);
|
|
97
|
+
else
|
|
98
|
+
resolve();
|
|
99
|
+
};
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
timedOut = true;
|
|
102
|
+
if (child.pid && process.platform !== "win32") {
|
|
103
|
+
try {
|
|
104
|
+
process.kill(-child.pid, "SIGKILL");
|
|
105
|
+
}
|
|
106
|
+
catch { }
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
child.kill("SIGKILL");
|
|
110
|
+
child.stdout.destroy();
|
|
111
|
+
child.stderr.destroy();
|
|
112
|
+
finish(failure("timeout", input, null, null, Buffer.concat(stdout), Buffer.concat(stderr), `completion gate timed out after ${TIMEOUT_MS}ms`));
|
|
113
|
+
}, TIMEOUT_MS);
|
|
114
|
+
child.once("error", () => finish(failure("spawn", input, null, null, Buffer.concat(stdout), Buffer.concat(stderr))));
|
|
115
|
+
child.once("close", (code, signal) => {
|
|
116
|
+
if (timedOut)
|
|
117
|
+
return;
|
|
118
|
+
if (code === 0)
|
|
119
|
+
finish();
|
|
120
|
+
else
|
|
121
|
+
finish(failure(signal ? "signal" : "failed", input, code, signal, Buffer.concat(stdout), Buffer.concat(stderr)));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|