@cmgmyr/hive 1.11.8 → 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.
@@ -18,10 +18,11 @@ export const BUILT_IN_PROJECT_YML = Object.freeze({
18
18
  context_checkpoint_percent: null,
19
19
  lead_turn_budget: null,
20
20
  dashboard: false,
21
- lead_sidebar: false,
22
- quiet_messaging: false,
21
+ lead_sidebar: true,
22
+ quiet_messaging: true,
23
23
  vars: Object.freeze({}),
24
24
  processes: Object.freeze({}),
25
+ completion_gate: null,
25
26
  });
26
27
  export const PROJECT_YML_KEYS = Object.freeze(Object.keys(BUILT_IN_PROJECT_YML));
27
28
  export function createBuiltInProjectYml() {
@@ -33,6 +34,12 @@ export function activeProfile(config) {
33
34
  const name = config?.profile;
34
35
  return name == null || name === NO_PROFILE ? null : name;
35
36
  }
37
+ export function quietMessagingEnabled(config) {
38
+ return config?.quiet_messaging ?? BUILT_IN_PROJECT_YML.quiet_messaging;
39
+ }
40
+ export function leadSidebarEnabled(config) {
41
+ return config?.lead_sidebar ?? BUILT_IN_PROJECT_YML.lead_sidebar;
42
+ }
36
43
  // First entry is the default; absent or empty means claude only (todo 526).
37
44
  export function allowedAgents(config) {
38
45
  const list = config?.agents;
@@ -141,7 +148,7 @@ function parseProcesses(value, path, warnings) {
141
148
  }
142
149
  return processes;
143
150
  }
144
- function applyLayer(root, path, source, config, sources, warnings) {
151
+ function applyLayer(root, path, source, config, sources, warnings, completionGateError) {
145
152
  const has = (key) => Object.hasOwn(root, key);
146
153
  const warn = (message) => warnings.push(`${path}: ${message}`);
147
154
  const assignNullable = (key, valid, message) => {
@@ -184,8 +191,10 @@ function applyLayer(root, path, source, config, sources, warnings) {
184
191
  if (value.auto_handoff !== undefined) {
185
192
  if (typeof value.auto_handoff === "boolean")
186
193
  budget.auto_handoff = value.auto_handoff;
187
- else
194
+ else {
195
+ budget.auto_handoff = false;
188
196
  warn(`lead_turn_budget.auto_handoff must be true or false; ignoring "${String(value.auto_handoff)}", so automatic handoff stays off.`);
197
+ }
189
198
  }
190
199
  config.lead_turn_budget = budget;
191
200
  sources.lead_turn_budget = source;
@@ -210,7 +219,7 @@ function applyLayer(root, path, source, config, sources, warnings) {
210
219
  if (has("lead_sidebar")) {
211
220
  const value = root.lead_sidebar;
212
221
  if (value == null) {
213
- config.lead_sidebar = false;
222
+ config.lead_sidebar = BUILT_IN_PROJECT_YML.lead_sidebar;
214
223
  sources.lead_sidebar = source;
215
224
  }
216
225
  else if (typeof value === "boolean") {
@@ -223,7 +232,7 @@ function applyLayer(root, path, source, config, sources, warnings) {
223
232
  if (has("quiet_messaging")) {
224
233
  const value = root.quiet_messaging;
225
234
  if (value == null) {
226
- config.quiet_messaging = false;
235
+ config.quiet_messaging = BUILT_IN_PROJECT_YML.quiet_messaging;
227
236
  sources.quiet_messaging = source;
228
237
  }
229
238
  else if (typeof value === "boolean") {
@@ -318,6 +327,31 @@ function applyLayer(root, path, source, config, sources, warnings) {
318
327
  }
319
328
  }
320
329
  }
330
+ if (has("completion_gate")) {
331
+ if (source === "global") {
332
+ warn("global hive.yml cannot define `completion_gate`; only project commands can be trusted.");
333
+ }
334
+ else {
335
+ const value = root.completion_gate;
336
+ if (value == null) {
337
+ config.completion_gate = null;
338
+ sources.completion_gate = source;
339
+ }
340
+ else if (Array.isArray(value) &&
341
+ value.length > 0 &&
342
+ value.every((part) => typeof part === "string" && !part.includes("\0")) &&
343
+ value[0].trim() !== "") {
344
+ config.completion_gate = value;
345
+ sources.completion_gate = source;
346
+ }
347
+ else {
348
+ const message = "completion_gate must be a non-empty list of strings without NUL, with a non-empty executable.";
349
+ warn(`${message} Refusing todo completion.`);
350
+ if (completionGateError)
351
+ completionGateError.value = message;
352
+ }
353
+ }
354
+ }
321
355
  for (const key of ["attach", "autoAttach"]) {
322
356
  if (source === "project" && has(key))
323
357
  warn(`${key} is global-only; ignoring it in project hive.yml.`);
@@ -333,18 +367,20 @@ export function resolveHiveConfig(projectPath) {
333
367
  if (global.root)
334
368
  applyLayer(global.root, globalConfigPath(), "global", config, sources, warnings);
335
369
  let projectValid = false;
370
+ const completionGateError = { value: undefined };
336
371
  if (projectPath != null) {
337
372
  const file = resolve(projectPath, "hive.yml");
338
373
  const project = parseFile(file, warnings);
339
374
  if (project) {
340
375
  projectValid = true;
341
- applyLayer(project, file, "project", config, sources, warnings);
376
+ applyLayer(project, file, "project", config, sources, warnings, completionGateError);
342
377
  }
343
378
  }
344
379
  const attachResult = resolveAttachSetting("attach", global.root, global.legacy, warnings);
345
380
  const autoAttachResult = resolveAttachSetting("autoAttach", global.root, global.legacy, warnings);
346
381
  return {
347
382
  config: global.root || projectValid ? config : null,
383
+ ...(completionGateError.value ? { completionGateError: completionGateError.value } : {}),
348
384
  warnings,
349
385
  sources,
350
386
  attach: attachResult,
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";
@@ -23,11 +24,12 @@ import { evaluateLeadWatch, leadSubscription } from "./leadWatch.js";
23
24
  import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SECONDS, } from "./backgroundTasks.js";
24
25
  import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
25
26
  import { PANE_LEASE_SETTLE_MS, releasePaneLease, tryAcquirePaneLease } from "./leaseStore.js";
26
- import { CROSS_SESSION_CLOSE, postClaudeWake, promptMarkerEvidenceSql, quietMessagingOn, socketGraceDue, socketSenderAddress, SOCKET_WAKE_FOOTER, } from "./claudeWake.js";
27
+ import { CROSS_SESSION_CLOSE, quietMessagingHooksLaunched, postClaudeWake, promptMarkerEvidenceSql, quietMessagingOn, socketGraceDue, socketSenderAddress, SOCKET_WAKE_FOOTER, } from "./claudeWake.js";
27
28
  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
  }
@@ -1949,7 +2065,8 @@ function socketRoute(timer, owner) {
1949
2065
  timer.deliver_messaging_pane_pid !== "" &&
1950
2066
  timer.deliver_messaging_pane_pid === timer.deliver_pane_pid &&
1951
2067
  timer.deliver_row_target === timer.deliver_pane &&
1952
- quietMessagingOn(timer.project_id));
2068
+ quietMessagingOn(timer.project_id) &&
2069
+ quietMessagingHooksLaunched(timer.project_id, timer.deliver_command));
1953
2070
  }
1954
2071
  function socketStillRegistered(timer, owner) {
1955
2072
  requireStillOwned(owner.rowId, owner.identity, `wake #${timer.id} socket delivery`);
@@ -1965,11 +2082,11 @@ function socketStillRegistered(timer, owner) {
1965
2082
  row.pid === row.pane_pid);
1966
2083
  }
1967
2084
  // A failed stamp means the firing was cancelled or superseded, so nothing is posted.
1968
- function stampRepeatingAttempt(timer, note, firstHeldAt) {
2085
+ function stampRepeatingAttempt(timer, note, typedSeen, firstHeldAt) {
1969
2086
  try {
1970
2087
  return (stmt(`UPDATE wakes SET socket_attempt_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), socket_delivery_note = ?,
1971
- delivery_method = ?, delivered_by = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
1972
- 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);
1973
2090
  }
1974
2091
  catch {
1975
2092
  return false;
@@ -2012,25 +2129,31 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
2012
2129
  const text = `${head}${note ? `, ${note}` : ""}] ${body + staleness + tail}\n\n${SOCKET_WAKE_FOOTER}`;
2013
2130
  if (!text.includes(CROSS_SESSION_CLOSE)) {
2014
2131
  if (repeating) {
2015
- if (!stampRepeatingAttempt(timer, note, firstHeldAt))
2132
+ if (!stampRepeatingAttempt(timer, note, typedSeen, firstHeldAt))
2016
2133
  return;
2017
2134
  firing = timer.fire_count;
2018
2135
  }
2019
2136
  else {
2020
2137
  bestEffortRun(`UPDATE wakes SET socket_attempt_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), socket_delivery_note = ?,
2021
- delivery_method = ?, delivered_by = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
2022
- 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);
2023
2140
  }
2024
2141
  const posted = await postClaudeWake({
2025
2142
  socketPath: timer.deliver_messaging_socket,
2026
2143
  senderAddress: socketSenderAddress(dataDir),
2027
2144
  text,
2028
- beforeWrite: () => socketStillRegistered(timer, owner),
2145
+ beforeWrite: () => !usageLimitHeld(timer) && socketStillRegistered(timer, owner),
2029
2146
  });
2030
2147
  if (posted) {
2031
2148
  forgetPaneAnswers(timer.deliver_pane, choices);
2032
2149
  return;
2033
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
+ }
2034
2157
  if (firing !== null && !claimRepeatingFallback(timer))
2035
2158
  return;
2036
2159
  note = withReDelivered(note);
@@ -2066,10 +2189,24 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
2066
2189
  requireStillOwned(owner.rowId, owner.identity, `wake #${timer.id} delivery`);
2067
2190
  if (firing !== null)
2068
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
+ }
2069
2198
  }
2070
2199
  : undefined);
2071
2200
  }
2072
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
+ }
2073
2210
  if (fallback && !pasted && firing === null) {
2074
2211
  bestEffortRun("UPDATE wakes SET delivery_method = ?, delivered_by = ? WHERE id = ? AND typed_at IS NULL", DELIVERY_METHOD.socket, deliveredByForFiring(timer), timer.id);
2075
2212
  }
@@ -2124,6 +2261,8 @@ async function retryUnconfirmedSocketWakes(snapshot, choices) {
2124
2261
  WHERE ((${socketAwaitingWhere("wakes.")}) OR (${repeatingSocketAwaitingWhere("wakes.")}))
2125
2262
  AND wakes.socket_attempt_at >= datetime('now', ?)`).all(LOG_RETENTION);
2126
2263
  for (const timer of awaiting) {
2264
+ if (usageLimitHeld(timer))
2265
+ continue;
2127
2266
  if (!socketFallbackDue(timer))
2128
2267
  continue;
2129
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