@cmgmyr/hive 1.11.6 → 1.11.7

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,6 +1,7 @@
1
1
  import { QUEEN_AUDIT_RETENTION, QUEEN_AUDIT_MAX_ROWS, recentQueenAudit } from "./queenAudit.js";
2
- import { ACTIVE_TIMER_WHERE } from "./wakeSql.js";
3
- export { ACTIVE_TIMER_WHERE } 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_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";
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
+ import { parseStoreTimestamp } from "./storeClock.js";
4
5
  import { runningBuildChange, runningBuildNotice, versionInfo } from "./version.js";
5
6
  import { existsSync, lstatSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
7
  import { createHash } from "node:crypto";
@@ -17,13 +18,14 @@ import { QUEEN_GENERATED_MARKER, readQueenBrief, renderQueenDashboard } from "./
17
18
  import { COMMAND_KIND, stoppingMarkerLive } from "./processes.js";
18
19
  import { closeAgentRow, PaneOwnershipLost, reapCodexHomeForClosedAgent, requireStillOwned, } from "./spawn.js";
19
20
  import { isLeadActorId, LEAD_ACTOR_PREFIX, LEAD_KIND, } from "./agentKinds.js";
20
- import { awaitingFirstPrompt, awaitingFirstPromptSql } from "./firstPrompt.js";
21
+ import { awaitingFirstPrompt, finishedIdle, finishedIdleSql } from "./firstPrompt.js";
21
22
  import { evaluateLeadWatch, leadSubscription } from "./leadWatch.js";
22
23
  import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SECONDS, } from "./backgroundTasks.js";
23
24
  import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
24
- import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress, SOCKET_WAKE_FOOTER } from "./claudeWake.js";
25
+ 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";
25
27
  import { driveLeadHandoff } from "./leadHandoff.js";
26
- import { handoffHoldsWake, HELD_REASON_HANDOFF } from "./leadHandoffState.js";
28
+ import { handoffHoldsWake } from "./leadHandoffState.js";
27
29
  import { FIRST_MESSAGE_LOG_STATE, humanPromptSql, readLeadTurnState } from "./leadState.js";
28
30
  import { retryQuietLeadMessages } from "./leadMessageDelivery.js";
29
31
  import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
@@ -282,7 +284,7 @@ function cancelTimer(timerId) {
282
284
  function cancelWithReason(timerId, reason) {
283
285
  bestEffortRun("UPDATE wakes SET cancelled_at = datetime('now'), held_reason = ? WHERE id = ? AND cancelled_at IS NULL", reason, timerId);
284
286
  }
285
- export const LOG_RETENTION = "-7 days";
287
+ export { LOG_RETENTION } from "./wakeSql.js";
286
288
  const LOG_MAX_ROWS = 20_000;
287
289
  function pruneStateLog() {
288
290
  try {
@@ -470,7 +472,7 @@ function maybeGenerateDashboards() {
470
472
  }
471
473
  }
472
474
  export function checkConfirmations() {
473
- const sentAt = "COALESCE(wakes.socket_attempt_at, wakes.typed_at)";
475
+ const sentAt = sentWakeAtSql("wakes.");
474
476
  const confirmationQuery = promptEvidenceSql(sentAt);
475
477
  try {
476
478
  const pending = stmt(`SELECT 1 AS hit FROM wakes
@@ -542,11 +544,7 @@ export async function tick(snapshot) {
542
544
  const now = stmt("SELECT datetime('now') AS now").get().now;
543
545
  const candidates = stmt(`SELECT wakes.*, ${DELIVER_ROW_COLUMNS}
544
546
  FROM wakes ${DELIVER_SOCKET_JOIN}
545
- WHERE wakes.cancelled_at IS NULL AND (
546
- (wakes.kind = 'delay' AND wakes.due_at <= datetime('now')
547
- AND (wakes.fired_at IS NULL OR wakes.repeat_every_ms IS NOT NULL))
548
- OR (wakes.kind != 'delay' AND wakes.fired_at IS NULL)
549
- )`).all();
547
+ WHERE ${schedulerCandidateWhere("wakes.")}`).all();
550
548
  const choices = new Map();
551
549
  for (const timer of candidates) {
552
550
  await isolated(`timer ${timer.id}`, async () => {
@@ -622,82 +620,14 @@ function holdTimer(timer, reason) {
622
620
  const count = timer.fire_count ?? null;
623
621
  bestEffortRun(`UPDATE wakes SET held_at = datetime('now'), held_reason = ?,
624
622
  first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
625
- WHERE id = ? AND cancelled_at IS NULL
626
- AND (fired_at IS NULL OR (repeat_every_ms IS NOT NULL AND due_at = ? AND (? IS NULL OR fire_count = ?))
627
- OR (${socketAwaitingWhere()}))`, reason, timer.id, timer.due_at, count, count);
623
+ WHERE id = ? AND ${holdableWakeWhere()}`, reason, timer.id, timer.due_at, count, count);
628
624
  }
629
- const HELD_REASON_MODAL_CHOICE = "pane is awaiting a modal choice (folder-trust or /model picker)";
630
625
  const LEAD_BOOT_BOX_WAIT_MS = 60_000;
631
- const HELD_REASON_LEAD_BOOT_INPUT_BOX = "the lead's input box is not on screen yet; holding while its session starts";
632
- export const HELD_REASON_LEAD_PANE_DEAD = "the lead's pane is not live right now (likely mid-restart); lead-owned wakes are exempt from " +
633
- "cancellation for this alone, so it is held rather than lost";
634
- const HELD_REASON_PANE_REISSUED_PREFIX = "the pane id recorded for this wake now belongs to a different pane than the one it was set " +
635
- "against (its pid no longer matches, most likely a tmux server restart reissuing the id); held " +
636
- "rather than typed into the wrong pane - ";
637
- const HELD_REASON_PANE_REISSUED_LEAD = `${HELD_REASON_PANE_REISSUED_PREFIX}run \`hive lead\` to re-point it at the live one`;
638
- const HELD_REASON_PANE_REISSUED_WORKER = `${HELD_REASON_PANE_REISSUED_PREFIX}nothing re-points a worker's wake automatically, so cancel ` +
639
- "it with wake_cancel and set a fresh one once the worker's pane is confirmed live, or leave it: " +
640
- "it will keep holding rather than deliver wrongly";
641
- const HELD_REASON_PANE_REISSUED_THEN_DEAD = `${HELD_REASON_PANE_REISSUED_PREFIX}the pane it was reissued to has since gone dead too; nothing ` +
642
- "re-points a worker's wake automatically, so cancel it with wake_cancel (any running lead may do " +
643
- "this even though the wake is not theirs) if it is no longer needed";
644
- export function wasHeldForPaneReissue(heldReason) {
645
- return heldReason != null && heldReason.startsWith(HELD_REASON_PANE_REISSUED_PREFIX);
646
- }
647
- const HELD_REASON_PANE_UNKNOWN_PREFIX = "pane ownership for this wake's row cannot be verified (no pane pid was recorded for it, its row now " +
648
- "records a different pane, or the pane is on another tmux socket); held rather than typed into a pane " +
649
- "that may not be its own - ";
650
- const HELD_REASON_PANE_UNKNOWN_LEAD = `${HELD_REASON_PANE_UNKNOWN_PREFIX}run \`hive lead\` to start or adopt a lead with a recorded pane and re-point it`;
651
- const HELD_REASON_PANE_UNKNOWN_WORKER = `${HELD_REASON_PANE_UNKNOWN_PREFIX}nothing re-points a worker's wake automatically, so cancel it with ` +
652
- "wake_cancel (any running lead may, even though the wake is not theirs), or leave it: it keeps holding " +
653
- "rather than deliver wrongly";
654
- const HELD_REASON_PANE_UNKNOWN_THEN_DEAD = `${HELD_REASON_PANE_UNKNOWN_PREFIX}that pane has since gone dead too; nothing re-points a worker's wake ` +
655
- "automatically, so cancel it with wake_cancel (any running lead may) if it is no longer needed";
656
- // One family for both identity holds, so a later dead pane re-holds them instead of cancelling.
657
- export function wasHeldForPaneIdentity(heldReason) {
658
- return wasHeldForPaneReissue(heldReason) || (heldReason?.startsWith(HELD_REASON_PANE_UNKNOWN_PREFIX) ?? false);
659
- }
660
- const identityThenDead = (heldReason) => wasHeldForPaneReissue(heldReason) ? HELD_REASON_PANE_REISSUED_THEN_DEAD : HELD_REASON_PANE_UNKNOWN_THEN_DEAD;
661
- export const HELD_REASON_ACTOR_CLOSED = "delivery actor closed: the row this wake was bound to is closed, so nothing was typed and the wake was " +
662
- "cancelled rather than delivered into a pane that row no longer owns";
663
- const HELD_REASON_LEAD_ROW_CLOSED = "the lead's row is closed, so no pane is this wake's to type into; held until `hive lead` starts or adopts a " +
664
- "lead and re-points it";
665
- export function isLeadRowClosedHold(heldReason) {
666
- return heldReason === HELD_REASON_LEAD_ROW_CLOSED;
667
- }
668
- const HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM = "delivery stopped after claim: ";
669
- export const HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX = "the pane's harness is not one hive can classify; ";
670
- export const HELD_REASON_UNCLASSIFIABLE_PANE = `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}neither the dialog guard nor the unsubmitted-text ` +
671
- "guard can read this screen and delivery would type blind. wake_set and wake_when_idle refuse such a " +
672
- "target outright now, so this wake predates that or its pane's command changed underneath it. Nothing " +
673
- "hive does on its own clears this: it lifts only when that pane is replaced by one hive starts itself " +
674
- "(`hive lead` after the pane exits). Cancel it with wake_cancel (any running lead may, even though the " +
675
- "wake is not theirs) and drive that pane with agent_send(keys: [...])";
676
- export function isUnclassifiablePaneHold(heldReason) {
677
- return heldReason != null && heldReason.startsWith(HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX);
678
- }
679
- export const HELD_REASON_COPY_MODE = "the pane is in tmux copy mode; tmux clears its bracketed-paste flag there, so the body would arrive " +
680
- "with no paste markers (losing everything before the last 1022-byte write) and the Enter would be " +
681
- "eaten by the mode instead of submitting - both silently, with tmux reporting success. It delivers " +
682
- "once whoever is reading that pane leaves copy mode";
683
- export const HELD_REASON_UNSUBMITTED_INPUT_PREFIX = "the pane's input box has unsubmitted human text; ";
684
- export const HELD_REASON_UNSUBMITTED_INPUT = `${HELD_REASON_UNSUBMITTED_INPUT_PREFIX}delivering now would paste the wake body onto it and submit ` +
685
- "both as one message";
686
- // Distinct from HELD_REASON_UNSUBMITTED_INPUT so the claim's debounce doesn't latch a transient
687
- // pane-resolution failure shut forever - the next tick retries instead of giving up permanently.
688
- const HELD_REASON_UNSUBMITTED_INPUT_OWNER_UNRESOLVED = `${HELD_REASON_UNSUBMITTED_INPUT_PREFIX}hive could not resolve a live pane for the wake's owner on ` +
689
- "this tick - retrying, since that may be transient rather than permanent";
690
- export function isUnsubmittedInputHold(heldReason) {
691
- return heldReason != null && heldReason.startsWith(HELD_REASON_UNSUBMITTED_INPUT_PREFIX);
692
- }
693
- export const HELD_REASON_CONVERSATION = "a human talked to this lead more recently than the conversation-hold window; holding so a wake " +
694
- "does not split an in-progress discussion - it delivers once the window passes or the hold's own " +
695
- "ceiling is reached, whichever comes first";
696
626
  function leadBootSessionIsFresh(agentId, panePid) {
697
627
  const row = stmt("SELECT pane_started_at FROM agents WHERE id = ? AND pane_pid = ?").get(agentId, panePid);
698
628
  if (!row?.pane_started_at)
699
629
  return false;
700
- const timestamp = Date.parse(`${row.pane_started_at.replace(" ", "T")}Z`);
630
+ const timestamp = parseStoreTimestamp(row.pane_started_at);
701
631
  return Number.isFinite(timestamp) && Date.now() - timestamp < LEAD_BOOT_BOX_WAIT_MS;
702
632
  }
703
633
  function leadInputBoxObservation(pane, command, cache) {
@@ -828,7 +758,7 @@ function insertNotice(timer, deliverActor, pane, body, parentTimerId) {
828
758
  function pendingNoticeFor(parentTimerId, conditions) {
829
759
  const placeholders = conditions.map(() => "?").join(", ");
830
760
  return stmt(`SELECT t.id FROM wakes t
831
- WHERE t.parent_wake_id = ? AND t.fired_at IS NULL AND t.cancelled_at IS NULL
761
+ WHERE t.parent_wake_id = ? AND ${pendingOneShotWhere("t.")}
832
762
  AND EXISTS (
833
763
  SELECT 1 FROM wake_idle_notices n WHERE n.notice_wake_id = t.id AND n.condition IN (${placeholders})
834
764
  )
@@ -836,7 +766,7 @@ function pendingNoticeFor(parentTimerId, conditions) {
836
766
  }
837
767
  function updateNoticeInPlace(noticeId, body) {
838
768
  return (stmt(`UPDATE wakes SET body = ?, created_at = datetime('now')
839
- WHERE id = ? AND fired_at IS NULL AND cancelled_at IS NULL`).run(body, noticeId).changes === 1);
769
+ WHERE id = ? AND ${pendingOneShotWhere()}`).run(body, noticeId).changes === 1);
840
770
  }
841
771
  function claimBlockNotice(timerId, agentId, blockedSince) {
842
772
  return (stmt(`INSERT OR IGNORE INTO wake_block_notices (wake_id, agent_id, blocked_since)
@@ -845,9 +775,8 @@ function claimBlockNotice(timerId, agentId, blockedSince) {
845
775
  const claimModalHoldWithNotice = db.transaction((timer, pane, body, target) => {
846
776
  const claimed = stmt(`UPDATE wakes SET held_at = datetime('now'), held_reason = ?,
847
777
  first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
848
- WHERE id = ? AND cancelled_at IS NULL
778
+ WHERE id = ? AND (${activeWakeWhere()})
849
779
  AND due_at IS ? AND body IS ? AND repeat_every_ms IS ?
850
- AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)
851
780
  AND held_reason IS NOT ?`).run(HELD_REASON_MODAL_CHOICE, timer.id, timer.due_at, timer.body, timer.repeat_every_ms, HELD_REASON_MODAL_CHOICE).changes === 1;
852
781
  if (!claimed)
853
782
  return false;
@@ -878,9 +807,8 @@ function noteModalHold(timer, snapshot) {
878
807
  const claimUnsubmittedInputHoldWithNotice = db.transaction((timer, pane, body) => {
879
808
  const claimed = stmt(`UPDATE wakes SET held_at = datetime('now'), held_reason = ?,
880
809
  first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
881
- WHERE id = ? AND cancelled_at IS NULL
810
+ WHERE id = ? AND (${activeWakeWhere()})
882
811
  AND due_at IS ? AND body IS ? AND repeat_every_ms IS ?
883
- AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)
884
812
  AND held_reason IS NOT ?`).run(HELD_REASON_UNSUBMITTED_INPUT, timer.id, timer.due_at, timer.body, timer.repeat_every_ms, HELD_REASON_UNSUBMITTED_INPUT).changes === 1;
885
813
  if (!claimed)
886
814
  return false;
@@ -942,7 +870,7 @@ function blockedWatchedAgents(timer, snapshot, tellActor) {
942
870
  blockedSince: r.episode,
943
871
  }));
944
872
  }
945
- const stillPending = (timerId) => stmt(`SELECT 1 AS hit FROM wakes WHERE id = ? AND cancelled_at IS NULL AND fired_at IS NULL`).get(timerId) !== undefined;
873
+ const stillPending = (timerId) => stmt(`SELECT 1 AS hit FROM wakes WHERE id = ? AND ${pendingOneShotWhere()}`).get(timerId) !== undefined;
946
874
  const claimBlockNoticeWithNotice = db.transaction((timer, agentId, blockedSince, tell, body) => {
947
875
  if (!stillPending(timer.id))
948
876
  return false;
@@ -998,9 +926,6 @@ function alreadyToldAbout(timerId, agentId, blockedSince) {
998
926
  }
999
927
  const CONDITION_IDLE = "idle";
1000
928
  const CONDITION_GONE = "gone";
1001
- const NOTICE_RETRY_AFTER = "-60 seconds";
1002
- const spentUntypedNotice = (alias) => `${alias}.fired_at IS NOT NULL AND COALESCE(${alias}.typed_at, ${alias}.socket_attempt_at) IS NULL ` +
1003
- `AND ${alias}.cancelled_at IS NULL AND ${alias}.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}')`;
1004
929
  const NOTICE_MAX_AGE = "-1 hours";
1005
930
  const ROSTER_STILL_GOING = 8;
1006
931
  const FINISHED_SHOWN_CAP = ROSTER_STILL_GOING;
@@ -1023,13 +948,13 @@ function standingIdleRows(timer) {
1023
948
  AND a.agent_state = 'idle' AND a.state_changed_at IS NOT NULL
1024
949
  ${OWNED_BY_WATCH}
1025
950
  AND ${unreported(CONDITION_IDLE, "a.state_changed_at")}
1026
- ORDER BY a.id`).all(timer.project_id, timer.deliver_actor, timer.owner, timer.id).filter((row) => !awaitingFirstPrompt(row));
951
+ ORDER BY a.id`).all(timer.project_id, timer.deliver_actor, timer.owner, timer.id).filter(finishedIdle);
1027
952
  }
1028
953
  function standingGoneRows(timer) {
1029
954
  return stmt(`SELECT ${CREW_COLUMNS}, a.closed_at AS episode
1030
955
  FROM agents a
1031
956
  WHERE a.project_id = ? AND a.kind = 'agent' AND a.status = 'closed' AND a.actor_id != ?
1032
- AND (a.agent_state != 'idle' OR ${awaitingFirstPromptSql("a")})
957
+ AND NOT (${finishedIdleSql("a")})
1033
958
  AND a.parked_at = ''
1034
959
  AND a.closed_at IS NOT NULL
1035
960
  ${OWNED_BY_WATCH}
@@ -1042,7 +967,7 @@ export function markGoneReported(agentId, projectId) {
1042
967
  FROM wakes t
1043
968
  JOIN agents a ON a.id = ?
1044
969
  WHERE t.project_id = ? AND t.kind = 'idle_any' AND t.watch_scope IS NOT NULL
1045
- AND t.cancelled_at IS NULL AND t.fired_at IS NULL
970
+ AND ${pendingOneShotWhere("t.")}
1046
971
  AND a.closed_at IS NOT NULL`).run(agentId, projectId);
1047
972
  }
1048
973
  export function seedGoneCursor(timerId, projectId) {
@@ -1077,7 +1002,7 @@ function stillGoingRows(timer) {
1077
1002
  return stmt(`SELECT a.name, a.actor_id, a.agent_state, a.state_changed_at, a.status, a.command, a.kind, a.resumed_at
1078
1003
  FROM agents a
1079
1004
  WHERE a.project_id = ? AND a.kind = 'agent' AND a.status = 'running'
1080
- AND (a.agent_state != 'idle' OR ${awaitingFirstPromptSql("a")}) AND a.actor_id != ?
1005
+ AND NOT (${finishedIdleSql("a")}) AND a.actor_id != ?
1081
1006
  ${OWNED_BY_WATCH}
1082
1007
  ORDER BY a.id`).all(timer.project_id, timer.deliver_actor, timer.owner);
1083
1008
  }
@@ -1226,7 +1151,7 @@ function standingNoticeBody(timer, finished, totalFinished, carriedTotal, span)
1226
1151
  more = Math.max(0, rows.length - ROSTER_STILL_GOING);
1227
1152
  shown = rows
1228
1153
  .slice(0, ROSTER_STILL_GOING)
1229
- .map((r) => awaitingFirstPrompt(r) && r.agent_state === "idle"
1154
+ .map((r) => r.agent_state === "idle" && !finishedIdle(r)
1230
1155
  ? `${r.name} (awaiting first assignment)`
1231
1156
  : `${r.name} (${stateNowClause(r)})`);
1232
1157
  }
@@ -1511,8 +1436,7 @@ function agedOutBody(timer) {
1511
1436
  // Telling a lead it need not re-arm, in hive's own generated prose, when it must, is how a lane ends
1512
1437
  // up sitting finished and unnoticed - which this project has paid for twice.
1513
1438
  export function watchStillWatchingClause(timer) {
1514
- const parent = stmt(`SELECT (cancelled_at IS NULL AND fired_at IS NULL
1515
- AND (max_wait_at IS NULL OR max_wait_at > datetime('now'))) AS watching,
1439
+ const parent = stmt(`SELECT (${watchWatchingWhere()}) AS watching,
1516
1440
  (watch_scope IS NOT NULL) AS standing
1517
1441
  FROM wakes WHERE id = ?`).get(timer.parent_wake_id);
1518
1442
  // The parent is not always a standing watch: a modal-hold or one-shot block notice's parent is
@@ -1660,39 +1584,28 @@ function deliverable(timer, snapshot, choices) {
1660
1584
  owner,
1661
1585
  };
1662
1586
  }
1663
- const PANE_CLAIM_TTL_SECONDS = 6;
1664
- const PANE_CLAIM_SETTLE_MS = 500;
1665
1587
  const PANE_CLAIM_OWNER = "hive:scheduler";
1666
- function acquirePaneClaim(projectId, key) {
1667
- try {
1668
- stmt("INSERT OR IGNORE INTO actors (id, name, kind) VALUES (?, 'hive scheduler', 'scheduler')").run(PANE_CLAIM_OWNER);
1669
- stmt("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at < datetime('now')").run(projectId, key);
1670
- const row = stmt(`INSERT INTO leases (project_id, lock_key, owner, expires_at)
1671
- VALUES (?, ?, ?, datetime('now', printf('+%d seconds', ?)))
1672
- ON CONFLICT(project_id, lock_key) DO NOTHING
1673
- RETURNING expires_at`).get(projectId, key, PANE_CLAIM_OWNER, PANE_CLAIM_TTL_SECONDS);
1674
- return row?.expires_at ?? null;
1675
- }
1676
- catch {
1677
- return null;
1678
- }
1679
- }
1680
1588
  // Serializes check-then-type per pane ACROSS scheduler instances: the per-wake claim is atomic but two
1681
1589
  // instances claiming different wakes for one pane would otherwise both paste before either submits.
1682
1590
  // A loser skips this tick and its wake stays unclaimed.
1683
1591
  async function withPaneClaim(timer, fn) {
1684
- const key = `wake-pane:${timer.deliver_socket}:${timer.deliver_pane}`;
1685
- const held = timer.deliver_pane ? acquirePaneClaim(timer.project_id, key) : null;
1592
+ const held = timer.deliver_pane
1593
+ ? tryAcquirePaneLease(timer.project_id, timer.deliver_socket, timer.deliver_pane, {
1594
+ id: PANE_CLAIM_OWNER,
1595
+ name: "hive scheduler",
1596
+ kind: "scheduler",
1597
+ })
1598
+ : null;
1686
1599
  if (held === null)
1687
1600
  return;
1688
1601
  try {
1689
1602
  // Settle: a slow TUI still showing the pasted text would make the next instance hold instead of type.
1690
1603
  if (await fn())
1691
- await sleep(PANE_CLAIM_SETTLE_MS);
1604
+ await sleep(PANE_LEASE_SETTLE_MS);
1692
1605
  }
1693
1606
  finally {
1694
1607
  // Match this acquire's expires_at: after a TTL takeover the owner is shared, so owner alone would delete the taker's row.
1695
- bestEffortRun("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at = ?", timer.project_id, key, held);
1608
+ releasePaneLease(timer.project_id, timer.deliver_socket, timer.deliver_pane, held);
1696
1609
  }
1697
1610
  }
1698
1611
  async function fireDelay(timer, snapshot, choices) {
@@ -1745,7 +1658,7 @@ async function fireDelayClaimed(timer, snapshot, choices) {
1745
1658
  function claimOneShot(timer) {
1746
1659
  return (stmt(`UPDATE wakes SET fired_at = datetime('now'), fire_count = fire_count + 1
1747
1660
  WHERE id = ? AND due_at IS ? AND body IS ? AND repeat_every_ms IS ?
1748
- AND fired_at IS NULL AND cancelled_at IS NULL`).run(timer.id, timer.due_at, timer.body, timer.repeat_every_ms).changes === 1);
1661
+ AND ${pendingOneShotWhere()}`).run(timer.id, timer.due_at, timer.body, timer.repeat_every_ms).changes === 1);
1749
1662
  }
1750
1663
  const GONE = { idle: true, gone: true, since: null };
1751
1664
  const UNKNOWN = { idle: false, gone: false, since: null };
@@ -1764,7 +1677,7 @@ function watchedStates(timer, snapshot) {
1764
1677
  return GONE;
1765
1678
  }
1766
1679
  return {
1767
- idle: !awaitingFirstPrompt(agent) && agent.agent_state === "idle",
1680
+ idle: finishedIdle(agent),
1768
1681
  gone: false,
1769
1682
  since: agent.state_changed_at,
1770
1683
  };
@@ -2003,24 +1916,8 @@ function recordLostAfterClaim(timer, pasted, why, firing = null) {
2003
1916
  cancelled_at = CASE WHEN repeat_every_ms IS NULL THEN datetime('now') ELSE cancelled_at END
2004
1917
  WHERE id = ? AND (? IS NULL OR fire_count = ?)`, HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM + why + (pasted ? " The body was pasted; no Enter was sent." : ""), pasted ? 1 : 0, timer.id, firing, firing);
2005
1918
  }
2006
- export const DELIVERY_METHOD = {
2007
- socket: "socket",
2008
- socketRepeating: "socket-repeating",
2009
- pty: "pty",
2010
- ptyAfterSocketTimeout: "pty-after-socket-timeout",
2011
- };
2012
- export const socketAwaitingWhere = (prefix = "") => `${prefix}delivery_method = '${DELIVERY_METHOD.socket}' AND ${prefix}confirmed_at IS NULL AND ${prefix}typed_at IS NULL AND ${prefix}cancelled_at IS NULL`;
2013
- // An attempt older than the row's latest claim belongs to an earlier firing (an old-build claim leaves it behind).
2014
- const currentAttempt = (p) => `${p}socket_attempt_at IS NOT NULL AND ${p}confirmed_at IS NULL AND ${p}typed_at IS NULL AND ${p}cancelled_at IS NULL
2015
- AND ${p}fired_at IS NOT NULL AND ${p}socket_attempt_at >= ${p}fired_at`;
2016
- const repeatingSocketAwaitingWhere = (prefix = "") => `${prefix}delivery_method = '${DELIVERY_METHOD.socketRepeating}' AND ${currentAttempt(prefix)}`;
2017
- const FIRING_DROPPED_NOTE = "dropped";
2018
- const repeatingFallbackClaimedWhere = (prefix = "") => `${prefix}repeat_every_ms IS NOT NULL AND ${prefix}delivery_method = '${DELIVERY_METHOD.ptyAfterSocketTimeout}' AND ${currentAttempt(prefix)}
2019
- AND ${prefix}socket_delivery_note IS NOT '${FIRING_DROPPED_NOTE}'`;
2020
- // Bounded like retry and confirmation: past retention neither can settle it, so it must stop deferring.
2021
- const REPEATING_FIRING_PENDING = `((${repeatingSocketAwaitingWhere()}) OR (${repeatingFallbackClaimedWhere()}))
2022
- AND socket_attempt_at >= datetime('now', '${LOG_RETENTION}')`;
2023
- const REPEATING_SOCKET_FIRING = `repeat_every_ms IS NOT NULL AND COALESCE(delivery_method, '') IN ('${DELIVERY_METHOD.socketRepeating}', '${DELIVERY_METHOD.ptyAfterSocketTimeout}')`;
1919
+ const REPEATING_FIRING_PENDING = repeatingFiringPendingWhere();
1920
+ const REPEATING_SOCKET_FIRING = repeatingSocketFiringWhere();
2024
1921
  function repeatingFiringPending(id) {
2025
1922
  try {
2026
1923
  return stmt(`SELECT 1 AS hit FROM wakes WHERE id = ? AND (${REPEATING_FIRING_PENDING})`).get(id) !== undefined;
@@ -2030,34 +1927,19 @@ function repeatingFiringPending(id) {
2030
1927
  }
2031
1928
  }
2032
1929
  const firingMarker = (timer) => `[hive wake #${timer.id} firing #${timer.fire_count}`;
2033
- const SOCKET_CONFIRM_GRACE_SECONDS = 60;
2034
1930
  const withReDelivered = (note) => (note ? `${note}, re-delivered` : "re-delivered");
2035
1931
  const promptEvidenceSql = (lowerBound) => `
2036
1932
  SELECT MIN(created_at) FROM agent_state_log
2037
1933
  WHERE actor_id = wakes.deliver_actor AND event = 'prompt' AND created_at >= ${lowerBound}
2038
1934
  AND (payload LIKE '%[hive wake #' || wakes.id || ']%'
2039
1935
  OR payload LIKE '%[hive wake #' || wakes.id || ',%')`;
2040
- const ENVELOPE_OPEN = '<cross-session-message from="';
2041
- const ENVELOPE_SENDER_CLOSE = '" from-name="hive">';
2042
- const EV_PROMPT = "(CASE WHEN json_valid(ev.payload) THEN COALESCE(json_extract(ev.payload, '$.prompt'), '') ELSE ev.payload END)";
2043
1936
  const EV_MARKER = "('[hive wake #' || wakes.id || ' firing #' || wakes.fire_count)";
2044
- const markerAt = (start) => `substr(${EV_PROMPT}, ${start}, length(${EV_MARKER}) + 1) IN (${EV_MARKER} || ']', ${EV_MARKER} || ',')`;
2045
- const EV_HEADER_END = `instr(${EV_PROMPT}, char(10))`;
1937
+ const markerAt = (promptSql, startSql) => `substr(${promptSql}, ${startSql}, length(${EV_MARKER}) + 1) IN (${EV_MARKER} || ']', ${EV_MARKER} || ',')`;
2046
1938
  // The current firing's exact marker, at prompt offset 0 (typed) or opening hive's envelope body (socket).
2047
1939
  const repeatingEvidenceSql = (select) => `
2048
1940
  SELECT ${select} FROM agent_state_log ev
2049
1941
  WHERE ev.actor_id = wakes.deliver_actor AND ev.event = 'prompt' AND ev.created_at >= wakes.socket_attempt_at
2050
- AND (${markerAt("1")}
2051
- OR (${EV_HEADER_END} > ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length}
2052
- AND substr(${EV_PROMPT}, 1, ${ENVELOPE_OPEN.length}) = '${ENVELOPE_OPEN}'
2053
- AND substr(${EV_PROMPT}, ${EV_HEADER_END} - ${ENVELOPE_SENDER_CLOSE.length}, ${ENVELOPE_SENDER_CLOSE.length}) = '${ENVELOPE_SENDER_CLOSE}'
2054
- AND instr(substr(${EV_PROMPT}, ${ENVELOPE_OPEN.length + 1}, ${EV_HEADER_END} - ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length + 1}), '"') = 0
2055
- AND ${markerAt(`${EV_HEADER_END} + 1`)}
2056
- AND instr(${EV_PROMPT}, char(10) || '${CROSS_SESSION_CLOSE}') > ${EV_HEADER_END}))`;
2057
- function quietMessagingOn(projectId) {
2058
- const project = stmt("SELECT path FROM projects WHERE id = ?").get(projectId);
2059
- return project !== undefined && loadProjectYml(project.path).config?.quiet_messaging === true;
2060
- }
1942
+ AND ${promptMarkerEvidenceSql(markerAt)}`;
2061
1943
  // Only a wake to a live, row-owned Claude lead whose socket was registered by this pane epoch.
2062
1944
  function socketRoute(timer, owner) {
2063
1945
  return (owner !== null &&
@@ -2141,7 +2023,7 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
2141
2023
  }
2142
2024
  const posted = await postClaudeWake({
2143
2025
  socketPath: timer.deliver_messaging_socket,
2144
- senderAddress: senderAddress(join(dataDir, "wake-sender.sock")),
2026
+ senderAddress: socketSenderAddress(dataDir),
2145
2027
  text,
2146
2028
  beforeWrite: () => socketStillRegistered(timer, owner),
2147
2029
  });
@@ -2220,15 +2102,7 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
2220
2102
  }
2221
2103
  // Measured from the lead's first turn end after the post, because a busy lead reads the post only then.
2222
2104
  function socketFallbackDue(timer) {
2223
- const firstStop = stmt("SELECT MIN(created_at) AS t FROM agent_state_log WHERE actor_id = ? AND event = 'stop' AND created_at >= ?").get(timer.deliver_actor, timer.socket_attempt_at).t;
2224
- let from = firstStop;
2225
- if (from === null) {
2226
- const turn = timer.deliver_row_id === null ? null : readLeadTurnState(timer.deliver_row_id);
2227
- if (turn !== null && turn.state === "working" && turn.pane_pid === timer.deliver_pane_pid)
2228
- return false;
2229
- from = timer.socket_attempt_at;
2230
- }
2231
- return stmt("SELECT (julianday('now') - julianday(?)) * 86400 >= ? AS due").get(from, SOCKET_CONFIRM_GRACE_SECONDS).due === 1;
2105
+ return socketGraceDue(timer.deliver_actor, timer.socket_attempt_at, timer.deliver_pane_pid, () => timer.deliver_row_id === null ? null : readLeadTurnState(timer.deliver_row_id));
2232
2106
  }
2233
2107
  async function fallbackClaimed(timer, snapshot, choices) {
2234
2108
  const decision = deliverable(timer, snapshot, choices);
@@ -3,6 +3,8 @@ import { realpathSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { DEFAULT_DATA_DIR } from "./dataDir.js";
6
+ import { parseEtimeSeconds } from "./etime.js";
7
+ import { parseStoreTimestamp } from "./storeClock.js";
6
8
  export const STALE_SERVER_BOUNDARY_SLACK_MS = 3000;
7
9
  export function readProcessEnvironment(pid) {
8
10
  try {
@@ -18,37 +20,11 @@ export function readProcessEnvironment(pid) {
18
20
  }
19
21
  }
20
22
  const serverEntrypoint = resolve(dirname(fileURLToPath(import.meta.url)), "index.js");
21
- export function elapsedSeconds(value) {
22
- const dayForm = /^(\d+)-(\d{1,2}):(\d{2}):(\d{2})$/.exec(value);
23
- const fields = dayForm ? dayForm.slice(2).map(Number) : value.split(":").map(Number);
24
- if ((!dayForm && fields.length !== 2 && fields.length !== 3) || fields.some((field) => !Number.isSafeInteger(field) || field < 0)) {
25
- return null;
26
- }
27
- let total;
28
- if (dayForm) {
29
- const [hours, minutes, seconds] = fields;
30
- if (hours > 23 || minutes > 59 || seconds > 59)
31
- return null;
32
- total = (Number(dayForm[1]) * 24 + hours) * 3600 + minutes * 60 + seconds;
33
- }
34
- else if (fields.length === 2) {
35
- const [minutes, seconds] = fields;
36
- if (seconds > 59)
37
- return null;
38
- total = minutes * 60 + seconds;
39
- }
40
- else {
41
- const [hours, minutes, seconds] = fields;
42
- if (minutes > 59 || seconds > 59)
43
- return null;
44
- total = hours * 3600 + minutes * 60 + seconds;
45
- }
46
- return Number.isSafeInteger(total) ? total : null;
47
- }
23
+ export const elapsedSeconds = parseEtimeSeconds;
48
24
  function appliedAtInstant(appliedAt) {
49
25
  if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(appliedAt))
50
26
  return null;
51
- const instant = Date.parse(`${appliedAt.replace(" ", "T")}Z`);
27
+ const instant = parseStoreTimestamp(appliedAt);
52
28
  return Number.isFinite(instant) ? instant : null;
53
29
  }
54
30
  function runsThisServer(command) {
@@ -2,6 +2,7 @@ import { db } from "./db.js";
2
2
  import { awaitingFirstPrompt } from "./firstPrompt.js";
3
3
  import { harnessFor } from "./harnesses.js";
4
4
  import { sanitizeEventForDisplay } from "./tmux.js";
5
+ import { parseStoreTimestamp } from "./storeClock.js";
5
6
  const prepared = new Map();
6
7
  function stmt(sql) {
7
8
  let s = prepared.get(sql);
@@ -11,9 +12,6 @@ function stmt(sql) {
11
12
  }
12
13
  return s;
13
14
  }
14
- function parseStoreTimestamp(ts) {
15
- return new Date(`${ts.replace(" ", "T")}Z`).getTime();
16
- }
17
15
  export function ageSecondsSince(storeTimestamp, now = Date.now()) {
18
16
  return Math.max(0, Math.round((now - parseStoreTimestamp(storeTimestamp)) / 1000));
19
17
  }
@@ -0,0 +1,4 @@
1
+ export function parseStoreTimestamp(value) {
2
+ const normalized = /(Z|[+-]\d\d:?\d\d)$/.test(value) ? value : `${value.replace(" ", "T")}Z`;
3
+ return new Date(normalized).getTime();
4
+ }
package/dist/teardown.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { parseStoreTimestamp } from "./storeClock.js";
3
4
  export const TEARDOWN_LOG = "teardowns.jsonl";
4
5
  // A backstop against a runaway writer, not a retention policy.
5
6
  export const TEARDOWN_MAX_RECORDS = 200;
@@ -11,7 +12,7 @@ export function teardownLogPath(dataDir) {
11
12
  export function sqlNow(now = new Date()) {
12
13
  return now.toISOString().replace("T", " ").slice(0, 23);
13
14
  }
14
- const sqlToDate = (ts) => new Date(`${ts.replace(" ", "T")}Z`);
15
+ const sqlToDate = (ts) => new Date(parseStoreTimestamp(ts));
15
16
  export function teardownWindow(from, to, basis) {
16
17
  const seconds = (sqlToDate(to).getTime() - sqlToDate(from).getTime()) / 1000;
17
18
  return { from, to, basis, width_seconds: Number.isFinite(seconds) ? Math.max(0, seconds) : 0 };
@@ -1,21 +1,8 @@
1
1
  import { z } from "zod";
2
- import { db } from "../db.js";
3
2
  import { currentActor, effectiveProjectId } from "../context.js";
3
+ import { extendOwnedLease, insertLease, purgeExpiredLeases, readLease, releaseOwnedLease } from "../leaseStore.js";
4
4
  import { run } from "../result.js";
5
5
  import { idParam, projectIdParam } from "./params.js";
6
- function purgeExpired(projectId) {
7
- db.prepare("DELETE FROM leases WHERE project_id = ? AND expires_at < datetime('now')").run(projectId);
8
- }
9
- export function readLease(projectId, key) {
10
- return db.prepare("SELECT * FROM leases WHERE project_id = ? AND lock_key = ?").get(projectId, key);
11
- }
12
- export function extendOwnedLease(projectId, key, actor, ttlSeconds) {
13
- const info = db
14
- .prepare(`UPDATE leases SET expires_at = datetime('now', printf('+%d seconds', ?))
15
- WHERE project_id = ? AND lock_key = ? AND owner = ?`)
16
- .run(ttlSeconds, projectId, key, actor);
17
- return info.changes > 0;
18
- }
19
6
  export function registerLeases(server) {
20
7
  server.registerTool("lease_acquire", {
21
8
  description: "Try to take a named lease on state another session could also change, such as a shared checkout, a dev database or a port (non-blocking). Re-taking your own lease extends it. Leases expire on their own.",
@@ -40,14 +27,9 @@ export function registerLeases(server) {
40
27
  },
41
28
  }, (args) => run(() => {
42
29
  const projectId = effectiveProjectId(args.project_id);
43
- purgeExpired(projectId);
30
+ purgeExpiredLeases(projectId);
44
31
  const actor = currentActor();
45
- const inserted = db
46
- .prepare(`INSERT INTO leases (project_id, lock_key, owner, expires_at)
47
- VALUES (?, ?, ?, datetime('now', printf('+%d seconds', ?)))
48
- ON CONFLICT(project_id, lock_key) DO NOTHING
49
- RETURNING expires_at`)
50
- .get(projectId, args.key, actor, args.ttl_seconds);
32
+ const inserted = insertLease(projectId, args.key, actor, args.ttl_seconds);
51
33
  if (inserted) {
52
34
  return { project_id: projectId, key: args.key, acquired: true, expires_at: inserted.expires_at };
53
35
  }
@@ -91,9 +73,7 @@ export function registerLeases(server) {
91
73
  outputSchema: { project_id: idParam, key: z.string(), released: z.boolean() },
92
74
  }, (args) => run(() => {
93
75
  const projectId = effectiveProjectId(args.project_id);
94
- const info = db
95
- .prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND owner = ?")
96
- .run(projectId, args.key, currentActor());
97
- return { project_id: projectId, key: args.key, released: info.changes > 0 };
76
+ const released = releaseOwnedLease(projectId, args.key, currentActor());
77
+ return { project_id: projectId, key: args.key, released };
98
78
  }));
99
79
  }
@@ -6,8 +6,8 @@ import { commitQueenStateWrite } from "../queenAudit.js";
6
6
  import { currentActor, effectiveProjectId, getProject, isQueenLead, } from "../context.js";
7
7
  import { run } from "../result.js";
8
8
  import { summaryLiveness } from "../agentRecords.js";
9
- import { LOG_RETENTION, OWNED_BY_WATCH, WATCH_SCOPE_PROJECT, seedGoneCursor, } from "../scheduler.js";
10
- import { ACTIVE_TIMER_WHERE } from "../wakeSql.js";
9
+ import { OWNED_BY_WATCH, WATCH_SCOPE_PROJECT, seedGoneCursor, } from "../scheduler.js";
10
+ import { ACTIVE_TIMER_WHERE, LOG_RETENTION, firedOneShotWhere, pendingOneShotWhere, wakeConfirmation, } from "../wakeSql.js";
11
11
  import { idParam, projectIdParam } from "./params.js";
12
12
  import { cutToUnitBudget } from "../slug.js";
13
13
  import { awaitingFirstPrompt } from "../firstPrompt.js";
@@ -29,7 +29,7 @@ const RECENTLY_FIRED_LIMIT = 10;
29
29
  function recentlyFiredWakes(projectId) {
30
30
  return db
31
31
  .prepare(`SELECT * FROM wakes
32
- WHERE project_id = ? AND cancelled_at IS NULL AND fired_at IS NOT NULL AND repeat_every_ms IS NULL
32
+ WHERE project_id = ? AND ${firedOneShotWhere()}
33
33
  AND fired_at >= datetime('now', ?)
34
34
  ORDER BY fired_at DESC, id DESC LIMIT ${RECENTLY_FIRED_LIMIT}`)
35
35
  .all(projectId, LOG_RETENTION);
@@ -52,15 +52,7 @@ function deliveryState(t, hasChannel) {
52
52
  held_at: t.held_at,
53
53
  held_reason: t.held_reason,
54
54
  confirmed_at: t.confirmed_at,
55
- confirmation: t.typed_at == null && t.socket_attempt_at == null
56
- ? null
57
- : t.confirmed_at != null
58
- ? "confirmed"
59
- : !hasChannel(t.deliver_actor)
60
- ? "no_confirmation_channel"
61
- : t.typed_busy === 1
62
- ? "unconfirmed_busy"
63
- : "unconfirmed",
55
+ confirmation: wakeConfirmation(t, (t.typed_at == null && t.socket_attempt_at == null) || t.confirmed_at != null || hasChannel(t.deliver_actor)),
64
56
  delivery_method: t.delivery_method,
65
57
  delivered_by: t.delivered_by,
66
58
  socket_attempt_at: t.socket_attempt_at,
@@ -408,13 +400,13 @@ export function registerWakes(server) {
408
400
  .prepare(`SELECT id, watch FROM wakes
409
401
  WHERE project_id = ? AND owner = ? AND kind = ?
410
402
  AND watch_scope IS NULL AND deliver_actor = ? AND deliver_pane = ?
411
- AND fired_at IS NULL AND cancelled_at IS NULL
403
+ AND ${pendingOneShotWhere()}
412
404
  AND id NOT IN (SELECT wake_id FROM lead_idle_subscriptions)`)
413
405
  .all(projectId, currentActor(), `idle_${mode}`, delivery.actor, delivery.pane)
414
406
  .filter((w) => sameSet(w.watch))
415
407
  .map((w) => w.id);
416
408
  const superseded = candidates.filter((id) => db
417
- .prepare("UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ? AND cancelled_at IS NULL AND fired_at IS NULL")
409
+ .prepare(`UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ? AND ${pendingOneShotWhere()}`)
418
410
  .run(id).changes === 1);
419
411
  const info = db
420
412
  .prepare(`INSERT INTO wakes (project_id, owner, body, kind, watch, deliver_actor, deliver_pane,
@@ -577,7 +569,7 @@ export function registerWakes(server) {
577
569
  const notices = info.changes > 0
578
570
  ? db
579
571
  .prepare(`UPDATE wakes SET cancelled_at = datetime('now')
580
- WHERE parent_wake_id = ? AND cancelled_at IS NULL AND fired_at IS NULL`)
572
+ WHERE parent_wake_id = ? AND ${pendingOneShotWhere()}`)
581
573
  .run(args.wake_id).changes
582
574
  : 0;
583
575
  return { wake_id: args.wake_id, cancelled: info.changes > 0, cancelled_notices: notices };