@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/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("SELECT id, codex_home FROM agents WHERE codex_home != '' AND status = 'closed' AND parked_at = ''").all();
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
- reaped += 1;
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
- if (closeAgentRow(agent.id)) {
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.")}`).all();
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 timer of candidates) {
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
- await withPaneClaim(timer, () => fireDelayClaimed(timer, snapshot, choices));
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 if (!claimOneShot(timer)) {
1653
- return false;
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
- return (stmt(`UPDATE wakes SET fired_at = datetime('now'), fire_count = fire_count + 1
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()}`).run(timer.id, timer.due_at, timer.body, timer.repeat_every_ms).changes === 1);
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 || !claimOneShot(timer))
1829
+ if (!decision.ok)
1725
1830
  return false;
1726
- await deliver(timer, STANDING_EXPIRED_NOTE, choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
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 || !claimOneShot(timer))
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 || !claimOneShot(timer))
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
- "WHERE id = ? AND status = 'closed'");
254
+ `WHERE id = ? AND status = 'closed'${recoveryOnly ? " AND recovery_state = 'pending'" : ""}`);
253
255
  }
254
256
  function restoreFlippedRow(agentId, before) {
255
- db.prepare(`UPDATE agents SET ${RESUME_FLIP_COLUMNS.map((c) => `${c} = ?`).join(", ")} ` +
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 = ? WHERE id = ? AND status = 'running' AND tmux_target = ?";
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
- // Row status alone is not the orphan test: a PARKED row is `status = 'closed'` too
404
- // (parkAgentRow above), and agent_park expects to resume it, cold-cache and all. `parked_at = ''`
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
- const before = db.prepare("SELECT transcript_path FROM agents WHERE id = ?").get(agentId);
426
- const oldTranscriptPath = before?.transcript_path ?? "";
427
- const dest = oldTranscriptPath ? preservedRolloutPath(codexHome, oldTranscriptPath) : undefined;
428
- reapCodexHome(codexHome);
429
- if (dest && existsSync(dest)) {
430
- db.prepare("UPDATE agents SET transcript_path = ? WHERE id = ? AND transcript_path = ?").run(dest, agentId, oldTranscriptPath);
431
- }
432
- db.prepare("UPDATE agents SET codex_home = '' WHERE id = ? AND codex_home = ?").run(agentId, codexHome);
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
+ }