@cmgmyr/hive 1.6.0 → 1.7.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
@@ -12,7 +12,7 @@ import { getProjectByPath, listProjects, queenHomeDir } from "./context.js";
12
12
  import { collectPortfolio } from "./portfolio.js";
13
13
  import { QUEEN_GENERATED_MARKER, readQueenBrief, renderQueenDashboard } from "./queenDashboard.js";
14
14
  import { COMMAND_KIND, stoppingMarkerLive } from "./processes.js";
15
- import { closeAgentRow, isLeadActorId, LEAD_ACTOR_PREFIX, LEAD_KIND, reapCodexHomeForClosedAgent } from "./spawn.js";
15
+ import { closeAgentRow, isLeadActorId, LEAD_ACTOR_PREFIX, LEAD_KIND, PaneOwnershipLost, reapCodexHomeForClosedAgent, requireStillOwned, } from "./spawn.js";
16
16
  import { awaitingFirstPrompt, awaitingFirstPromptSql } from "./firstPrompt.js";
17
17
  import { evaluateLeadWatch, leadSubscription } from "./leadWatch.js";
18
18
  import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SECONDS, } from "./backgroundTasks.js";
@@ -20,7 +20,7 @@ import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
20
20
  import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
21
21
  import { transcriptDir, readContextFill } from "./transcript.js";
22
22
  import { ageSecondsSince, describeLastLogEvent, humanizeAge, lastLogEvent, reportsAgentStateLog, } from "./stateProvenance.js";
23
- import { capturePane, foreignSocket, holdsHumanInput, liveTargets, maskChoiceMarker, paneInCopyMode, paneReissued, rowAlive, rowAliveProbe, rowLiveProbe, renderTrailerView, sanitizeTail, sendText, tailCaptureLines, sleep, tmuxSocketPath, } from "./tmux.js";
23
+ import { capturePane, foreignSocket, holdsHumanInput, liveTargets, maskChoiceMarker, observationFailed, paneInCopyMode, paneReissued, rowAlive, rowAliveProbe, rowLiveProbe, rowOwnership, renderTrailerView, sanitizeTail, sendText, tailCaptureLines, sleep, tmuxSocketPath, } from "./tmux.js";
24
24
  import { appendTeardown, NOT_ATTRIBUTED, sqlNow, teardownWindow, TEARDOWN_TRIGGER, } from "./teardown.js";
25
25
  export const WATCH_SCOPE_PROJECT = "project";
26
26
  const isStandingWatch = (timer) => timer.watch_scope === WATCH_SCOPE_PROJECT;
@@ -28,6 +28,24 @@ export const DELIVER_SOCKET_JOIN = `LEFT JOIN agents ON agents.id = (
28
28
  SELECT a.id FROM agents a WHERE a.actor_id = wakes.deliver_actor
29
29
  ORDER BY (a.status = 'running') DESC, a.id DESC LIMIT 1
30
30
  )`;
31
+ const DELIVER_ROW_COLUMNS = `COALESCE(agents.tmux_socket, '') AS deliver_socket,
32
+ COALESCE(agents.pane_pid, '') AS deliver_pane_pid,
33
+ COALESCE(agents.command, '') AS deliver_command,
34
+ agents.id AS deliver_row_id, agents.status AS deliver_row_status,
35
+ COALESCE(agents.tmux_target, '') AS deliver_row_target`;
36
+ // A row's pid certifies only the pane that row records, never a different deliver_pane.
37
+ const deliveryIdentity = (row) => ({
38
+ tmux_target: row.deliver_pane,
39
+ tmux_socket: row.deliver_socket,
40
+ pane_pid: row.deliver_row_target === row.deliver_pane ? row.deliver_pane_pid : "",
41
+ });
42
+ function deliveryOwnership(row, snapshot) {
43
+ if (row.deliver_row_id === null)
44
+ return "raw";
45
+ if (row.deliver_row_status !== "running")
46
+ return "closed";
47
+ return rowOwnership(deliveryIdentity(row), snapshot);
48
+ }
31
49
  export const ACTIVE_TIMER_WHERE = "cancelled_at IS NULL AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)";
32
50
  const SETTLE_WINDOW = "-15 seconds";
33
51
  const prepared = new Map();
@@ -92,7 +110,7 @@ const teardownMember = (row, swept) => ({
92
110
  // Leads are READ for the roster and never swept: janitor's own query excludes them deliberately, and
93
111
  // a crew death that took a lead's pane is exactly the casualty the roster would otherwise omit.
94
112
  function goneLeadRows(snapshot) {
95
- return stmt(`SELECT ${CREW_ROW_COLUMNS} FROM agents WHERE status = 'running' AND kind = ? AND tmux_target != ''`).all(LEAD_KIND).filter((row) => rowAliveProbe(row.tmux_socket, row.tmux_target, snapshot).live === false);
113
+ return stmt(`SELECT ${CREW_ROW_COLUMNS} FROM agents WHERE status = 'running' AND kind = ? AND tmux_target != ''`).all(LEAD_KIND).filter((row) => ["gone", "reissued"].includes(rowOwnership(row, snapshot)));
96
114
  }
97
115
  function recordTeardown(snapshot, swept) {
98
116
  try {
@@ -156,11 +174,11 @@ function reportDeadProcess(agent, snapshot) {
156
174
  // interrupted between the two is not reported as a crash.
157
175
  if (stoppingMarkerLive(agent.project_id, agent.id))
158
176
  return;
159
- const lead = stmt(`SELECT actor_id, tmux_target, tmux_socket FROM agents
177
+ const lead = stmt(`SELECT actor_id, tmux_target, tmux_socket, pane_pid FROM agents
160
178
  WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id DESC LIMIT 1`).get(agent.project_id, LEAD_KIND);
161
179
  // Never mint a notice hive could not deliver: with no live lead pane there is nobody this could
162
180
  // ever reach, and the row would sit in wakes forever.
163
- if (!lead?.tmux_target || rowAlive(lead.tmux_socket, lead.tmux_target, snapshot) !== true)
181
+ if (!lead?.tmux_target || rowOwnership(lead, snapshot) !== "live")
164
182
  return;
165
183
  insertNotice({ project_id: agent.project_id, owner: lead.actor_id }, lead.actor_id, lead.tmux_target, deadProcessBody(agent.name), null);
166
184
  }
@@ -176,9 +194,7 @@ export function reportRunningBuildChange(snapshot) {
176
194
  return;
177
195
  const lead = stmt(`SELECT project_id, actor_id, tmux_target, tmux_socket, pane_pid, command FROM agents
178
196
  WHERE actor_id = ? AND kind = ? AND status = 'running'`).get(actor, LEAD_KIND);
179
- if (!lead?.tmux_target || !screenClassifiable(lead.command) ||
180
- rowAlive(lead.tmux_socket, lead.tmux_target, snapshot) !== true ||
181
- paneReissued(lead.pane_pid, rowAliveProbe(lead.tmux_socket, lead.tmux_target, snapshot)))
197
+ if (!lead?.tmux_target || !screenClassifiable(lead.command) || rowOwnership(lead, snapshot) !== "live")
182
198
  return;
183
199
  insertNotice({ project_id: lead.project_id, owner: lead.actor_id }, lead.actor_id, lead.tmux_target, runningBuildNotice(change), null);
184
200
  reportedBuilds.add(change.disk.build_id);
@@ -220,15 +236,19 @@ export function janitor(snapshot = liveTargets()) {
220
236
  // recording that as a teardown manufactures an incident out of an unset PATH.
221
237
  const sawARealServer = snapshot.serverAnswered !== false;
222
238
  const teardown = sawARealServer && snapshot.panes.size === 0 && swept.length > 0 ? recordTeardown(snapshot, swept) : undefined;
223
- const wakes = stmt(`SELECT wakes.id, wakes.due_at, wakes.held_reason, wakes.deliver_pane,
224
- COALESCE(agents.tmux_socket, '') AS deliver_socket
239
+ const wakes = stmt(`SELECT wakes.id, wakes.due_at, wakes.held_reason, wakes.deliver_pane, wakes.deliver_actor, ${DELIVER_ROW_COLUMNS}
225
240
  FROM wakes ${DELIVER_SOCKET_JOIN}
226
241
  WHERE ${ACTIVE_TIMER_WHERE} AND wakes.deliver_actor NOT LIKE ?
227
242
  AND wakes.created_at < datetime('now', ?)`).all(`${LEAD_ACTOR_PREFIX}%`, SETTLE_WINDOW);
228
243
  for (const timer of wakes) {
244
+ if (deliveryOwnership(timer, snapshot) === "closed") {
245
+ cancelWithReason(timer.id, HELD_REASON_ACTOR_CLOSED);
246
+ cancelledTimers += 1;
247
+ continue;
248
+ }
229
249
  if (rowAlive(timer.deliver_socket, timer.deliver_pane, snapshot) === false) {
230
- if (wasHeldForPaneReissue(timer.held_reason)) {
231
- holdTimer(timer, HELD_REASON_PANE_REISSUED_THEN_DEAD);
250
+ if (wasHeldForPaneIdentity(timer.held_reason)) {
251
+ holdTimer(timer, identityThenDead(timer.held_reason));
232
252
  }
233
253
  else {
234
254
  cancelTimer(timer.id);
@@ -247,6 +267,9 @@ export function janitor(snapshot = liveTargets()) {
247
267
  function cancelTimer(timerId) {
248
268
  stmt("UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ?").run(timerId);
249
269
  }
270
+ function cancelWithReason(timerId, reason) {
271
+ bestEffortRun("UPDATE wakes SET cancelled_at = datetime('now'), held_reason = ? WHERE id = ? AND cancelled_at IS NULL", reason, timerId);
272
+ }
250
273
  export const LOG_RETENTION = "-7 days";
251
274
  const LOG_MAX_ROWS = 20_000;
252
275
  function pruneStateLog() {
@@ -469,9 +492,7 @@ export async function tick(snapshot) {
469
492
  maybeBackupHourly(db, dataDir);
470
493
  maybeGenerateDashboards();
471
494
  const now = stmt("SELECT datetime('now') AS now").get().now;
472
- const candidates = stmt(`SELECT wakes.*, COALESCE(agents.tmux_socket, '') AS deliver_socket,
473
- COALESCE(agents.pane_pid, '') AS deliver_pane_pid,
474
- COALESCE(agents.command, '') AS deliver_command
495
+ const candidates = stmt(`SELECT wakes.*, ${DELIVER_ROW_COLUMNS}
475
496
  FROM wakes ${DELIVER_SOCKET_JOIN}
476
497
  WHERE wakes.cancelled_at IS NULL AND (
477
498
  (wakes.kind = 'delay' AND wakes.due_at <= datetime('now')
@@ -567,6 +588,28 @@ const HELD_REASON_PANE_REISSUED_THEN_DEAD = `${HELD_REASON_PANE_REISSUED_PREFIX}
567
588
  export function wasHeldForPaneReissue(heldReason) {
568
589
  return heldReason != null && heldReason.startsWith(HELD_REASON_PANE_REISSUED_PREFIX);
569
590
  }
591
+ 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 " +
592
+ "records a different pane, or the pane is on another tmux socket); held rather than typed into a pane " +
593
+ "that may not be its own - ";
594
+ 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`;
595
+ const HELD_REASON_PANE_UNKNOWN_WORKER = `${HELD_REASON_PANE_UNKNOWN_PREFIX}nothing re-points a worker's wake automatically, so cancel it with ` +
596
+ "wake_cancel (any running lead may, even though the wake is not theirs), or leave it: it keeps holding " +
597
+ "rather than deliver wrongly";
598
+ 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 ` +
599
+ "automatically, so cancel it with wake_cancel (any running lead may) if it is no longer needed";
600
+ // One family for both identity holds, so a later dead pane re-holds them instead of cancelling.
601
+ export function wasHeldForPaneIdentity(heldReason) {
602
+ return wasHeldForPaneReissue(heldReason) || (heldReason?.startsWith(HELD_REASON_PANE_UNKNOWN_PREFIX) ?? false);
603
+ }
604
+ const identityThenDead = (heldReason) => wasHeldForPaneReissue(heldReason) ? HELD_REASON_PANE_REISSUED_THEN_DEAD : HELD_REASON_PANE_UNKNOWN_THEN_DEAD;
605
+ 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 " +
606
+ "cancelled rather than delivered into a pane that row no longer owns";
607
+ 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 " +
608
+ "lead and re-points it";
609
+ export function isLeadRowClosedHold(heldReason) {
610
+ return heldReason === HELD_REASON_LEAD_ROW_CLOSED;
611
+ }
612
+ const HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM = "delivery stopped after claim: ";
570
613
  export const HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX = "the pane's harness is not one hive can classify; ";
571
614
  export const HELD_REASON_UNCLASSIFIABLE_PANE = `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}neither the dialog guard nor the unsubmitted-text ` +
572
615
  "guard can read this screen and delivery would type blind. wake_set and wake_when_idle refuse such a " +
@@ -599,13 +642,14 @@ export const HELD_REASON_CONVERSATION = "a human talked to this lead more recent
599
642
  const CONVERSATION_HOLD_TTL_SECONDS = 5 * 60;
600
643
  const CONVERSATION_HOLD_TTL = `-${CONVERSATION_HOLD_TTL_SECONDS} seconds`;
601
644
  // Must stay well under NOTICE_MAX_AGE, past which noticeDisposition cancels a held notice rather than
602
- // typing stale news (it says so in a replacement wake; it is not silent). Measured against due_at, not first_held_at: cli.ts's hive-lead
645
+ // typing stale news (it says so in a replacement wake; it is not silent). Measured against due_at (idleCeilingStart for an idle wake), not first_held_at: cli.ts's hive-lead
603
646
  // re-point clears held_at (and so first_held_at) on every ordinary reattach, which would otherwise
604
647
  // launder this ceiling indefinitely.
605
648
  const CONVERSATION_HOLD_MAX = "-15 minutes";
606
- function conversationHoldsWake(timer) {
607
- if (timer.due_at !== null) {
608
- const withinCeiling = stmt(`SELECT ? >= datetime('now', ?) AS within`).get(timer.due_at, CONVERSATION_HOLD_MAX);
649
+ function conversationHoldsWake(timer, snapshot) {
650
+ const clockStart = timer.due_at ?? idleCeilingStart(timer, snapshot);
651
+ if (clockStart !== null) {
652
+ const withinCeiling = stmt(`SELECT ? >= datetime('now', ?) AS within`).get(clockStart, CONVERSATION_HOLD_MAX);
609
653
  if (!withinCeiling.within)
610
654
  return false;
611
655
  }
@@ -671,11 +715,11 @@ function standingBlockNoticeBody(timer, names, observedAt) {
671
715
  return lines.join("\n");
672
716
  }
673
717
  function ownerPaneIfLive(timer, snapshot) {
674
- const row = stmt(`SELECT tmux_target, tmux_socket FROM agents WHERE actor_id = ? AND status = 'running'
718
+ const row = stmt(`SELECT tmux_target, tmux_socket, pane_pid FROM agents WHERE actor_id = ? AND status = 'running'
675
719
  ORDER BY id DESC LIMIT 1`).get(timer.owner);
676
720
  if (!row?.tmux_target || snapshot === null)
677
721
  return null;
678
- return rowAlive(row.tmux_socket, row.tmux_target, snapshot) === true ? row.tmux_target : null;
722
+ return rowOwnership(row, snapshot) === "live" ? row.tmux_target : null;
679
723
  }
680
724
  function blockNoticeTarget(timer, snapshot) {
681
725
  const pane = ownerPaneIfLive(timer, snapshot);
@@ -813,7 +857,7 @@ const standingBlockedRows = (timer, tellActor) => stmt(`SELECT ${CREW_COLUMNS},
813
857
  function blockedWatchedAgents(timer, snapshot, tellActor) {
814
858
  const rows = isStandingWatch(timer) ? standingBlockedRows(timer, tellActor) : oneShotBlockedRows(timer);
815
859
  return rows
816
- .filter((r) => rowAlive(r.tmux_socket, r.tmux_target, snapshot) === true)
860
+ .filter((r) => rowOwnership(r, snapshot) === "live")
817
861
  .map((r) => ({
818
862
  id: r.id,
819
863
  name: r.name,
@@ -889,7 +933,7 @@ const unreported = (condition, episode) => `NOT EXISTS (
889
933
  WHERE n.wake_id = ? AND n.agent_id = a.id AND n.condition = '${condition}' AND n.episode = ${episode}
890
934
  AND NOT (nt.fired_at IS NOT NULL AND nt.typed_at IS NULL AND nt.cancelled_at IS NULL
891
935
  AND nt.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}')))`;
892
- const CREW_COLUMNS = `a.id, a.name, a.actor_id, a.tmux_target, a.tmux_socket, a.agent_state,
936
+ const CREW_COLUMNS = `a.id, a.name, a.actor_id, a.tmux_target, a.tmux_socket, a.pane_pid, a.agent_state,
893
937
  a.state_changed_at, a.status, a.command, a.kind, a.resumed_at, a.closed_at,
894
938
  a.cwd, a.session_id, a.transcript_path`;
895
939
  // Scopes a standing watch to the OWNER's own crew (bound to owner, not deliver_actor - a watch can
@@ -1201,7 +1245,7 @@ function noteStandingTransitions(timer, snapshot) {
1201
1245
  for (const row of standingIdleRows(timer)) {
1202
1246
  if (row.tmux_target === pane)
1203
1247
  continue;
1204
- if (rowAlive(row.tmux_socket, row.tmux_target, snapshot) !== true)
1248
+ if (rowOwnership(row, snapshot) !== "live")
1205
1249
  continue;
1206
1250
  if (!idleIsAFreshTransition(timer.id, row))
1207
1251
  continue;
@@ -1321,7 +1365,7 @@ function noteStalledCrew(timer, snapshot, choices) {
1321
1365
  if (row.agent_state === "waiting") {
1322
1366
  if (snapshot === null)
1323
1367
  continue;
1324
- if (rowAlive(row.tmux_socket, row.tmux_target, snapshot) !== true)
1368
+ if (rowOwnership(row, snapshot) !== "live")
1325
1369
  continue;
1326
1370
  if (awaitingChoice(row.tmux_target, row.command, choices) !== false)
1327
1371
  continue;
@@ -1414,33 +1458,63 @@ const ageOutNotice = db.transaction((timer) => {
1414
1458
  return;
1415
1459
  insertNotice(timer, timer.deliver_actor, timer.deliver_pane, agedOutBody(timer), null);
1416
1460
  });
1461
+ function freshDeliveryRow(timerId) {
1462
+ return stmt(`SELECT wakes.deliver_actor, wakes.deliver_pane, ${DELIVER_ROW_COLUMNS}
1463
+ FROM wakes ${DELIVER_SOCKET_JOIN} WHERE wakes.id = ? AND wakes.cancelled_at IS NULL`).get(timerId);
1464
+ }
1465
+ // Applies the hold, cancel or wait for a row-bound wake whose row does not own deliver_pane.
1466
+ function refuseUnowned(timer, ownership, snapshot) {
1467
+ const lead = isLeadActorId(timer.deliver_actor);
1468
+ switch (ownership) {
1469
+ case "closed":
1470
+ if (lead)
1471
+ holdTimer(timer, HELD_REASON_LEAD_ROW_CLOSED);
1472
+ else
1473
+ cancelWithReason(timer.id, HELD_REASON_ACTOR_CLOSED);
1474
+ return;
1475
+ case "unknown":
1476
+ // A failed observation re-evaluates next tick; it is never read as gone.
1477
+ if (!foreignSocket(timer.deliver_socket) && observationFailed(snapshot))
1478
+ return;
1479
+ holdTimer(timer, lead ? HELD_REASON_PANE_UNKNOWN_LEAD : HELD_REASON_PANE_UNKNOWN_WORKER);
1480
+ return;
1481
+ case "reissued":
1482
+ holdTimer(timer, lead ? HELD_REASON_PANE_REISSUED_LEAD : HELD_REASON_PANE_REISSUED_WORKER);
1483
+ return;
1484
+ case "gone":
1485
+ if (lead)
1486
+ holdTimer(timer, HELD_REASON_LEAD_PANE_DEAD);
1487
+ else if (wasHeldForPaneIdentity(timer.held_reason))
1488
+ holdTimer(timer, identityThenDead(timer.held_reason));
1489
+ else
1490
+ cancelTimer(timer.id);
1491
+ return;
1492
+ }
1493
+ }
1417
1494
  function deliverable(timer, snapshot, choices) {
1418
- const probe = snapshot
1419
- ? rowAliveProbe(timer.deliver_socket, timer.deliver_pane, snapshot)
1420
- : rowLiveProbe(timer.deliver_socket, timer.deliver_pane);
1421
- const live = probe.live;
1422
- if (live === null)
1495
+ const row = freshDeliveryRow(timer.id);
1496
+ if (!row || row.deliver_pane !== timer.deliver_pane)
1423
1497
  return { ok: false };
1424
- if (!live) {
1425
- if (isLeadActorId(timer.deliver_actor)) {
1426
- holdTimer(timer, HELD_REASON_LEAD_PANE_DEAD);
1427
- }
1428
- else {
1429
- if (wasHeldForPaneReissue(timer.held_reason)) {
1430
- holdTimer(timer, HELD_REASON_PANE_REISSUED_THEN_DEAD);
1431
- }
1432
- else {
1433
- cancelTimer(timer.id);
1434
- }
1498
+ let owner = null;
1499
+ const ownership = deliveryOwnership(row, snapshot);
1500
+ if (ownership === "raw") {
1501
+ const probe = snapshot
1502
+ ? rowAliveProbe(row.deliver_socket, row.deliver_pane, snapshot)
1503
+ : rowLiveProbe(row.deliver_socket, row.deliver_pane);
1504
+ if (probe.live === null)
1505
+ return { ok: false };
1506
+ if (!probe.live) {
1507
+ refuseUnowned(timer, "gone", snapshot);
1508
+ return { ok: false };
1435
1509
  }
1436
- return { ok: false };
1437
1510
  }
1438
- if (paneReissued(timer.deliver_pane_pid, probe)) {
1439
- holdTimer(timer, isLeadActorId(timer.deliver_actor)
1440
- ? HELD_REASON_PANE_REISSUED_LEAD
1441
- : HELD_REASON_PANE_REISSUED_WORKER);
1511
+ else if (ownership !== "live") {
1512
+ refuseUnowned(timer, ownership, snapshot);
1442
1513
  return { ok: false };
1443
1514
  }
1515
+ else {
1516
+ owner = { rowId: row.deliver_row_id, identity: deliveryIdentity(row) };
1517
+ }
1444
1518
  // Above both pane reads: this population must not be captured at all, and the verdicts would be
1445
1519
  // wrong in opposite directions if they were.
1446
1520
  if (!screenClassifiable(timer.deliver_command)) {
@@ -1469,15 +1543,11 @@ function deliverable(timer, snapshot, choices) {
1469
1543
  // so it sits after them. No notice-claim of its own: the human being held for is the human at the
1470
1544
  // keyboard, and the statusline is what tells them (see the two dead-ends on notice-claim latching
1471
1545
  // and on splitting a hold reason in two).
1472
- if (isLeadActorId(timer.deliver_actor) && conversationHoldsWake(timer)) {
1546
+ if (isLeadActorId(timer.deliver_actor) && conversationHoldsWake(timer, snapshot)) {
1473
1547
  holdTimer(timer, HELD_REASON_CONVERSATION);
1474
1548
  return { ok: false };
1475
1549
  }
1476
- const pid = timer.deliver_pane_pid === "" || probe.pid === null
1477
- ? "no-fact"
1478
- : probe.pid === timer.deliver_pane_pid
1479
- ? "ok"
1480
- : "reissued";
1550
+ const pid = owner === null ? "no-fact" : "ok";
1481
1551
  const dialogSeen = dialog === false ? "no" : "unknown";
1482
1552
  const boxState = cacheEntry(timer.deliver_pane, choices).box;
1483
1553
  const box = boxState === undefined ? "unknown" : boxState === null ? "absent" : boxState.state;
@@ -1485,6 +1555,7 @@ function deliverable(timer, snapshot, choices) {
1485
1555
  ok: true,
1486
1556
  typedSeen: `live=yes pid=${pid} dialog=${dialogSeen} box=${box}`,
1487
1557
  firstHeldAt: timer.held_at != null ? timer.first_held_at : null,
1558
+ owner,
1488
1559
  };
1489
1560
  }
1490
1561
  const PANE_CLAIM_TTL_SECONDS = 6;
@@ -1557,7 +1628,7 @@ async function fireDelayClaimed(timer, snapshot, choices) {
1557
1628
  }
1558
1629
  if (!claimed)
1559
1630
  return false;
1560
- await deliver(timer, "", choices, decision.typedSeen, decision.firstHeldAt);
1631
+ await deliver(timer, "", choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
1561
1632
  return true;
1562
1633
  }
1563
1634
  function claimOneShot(timer) {
@@ -1573,10 +1644,10 @@ function watchedStates(timer, snapshot) {
1573
1644
  const agent = stmt(`SELECT *, created_at < datetime('now', ?) AS settled FROM agents WHERE id = ?`).get(SETTLE_WINDOW, id);
1574
1645
  if (!agent || agent.status !== "running")
1575
1646
  return GONE;
1576
- const alive = rowAlive(agent.tmux_socket, agent.tmux_target, snapshot);
1577
- if (alive === null)
1647
+ const ownership = rowOwnership(agent, snapshot);
1648
+ if (ownership === "unknown")
1578
1649
  return UNKNOWN;
1579
- if (!alive) {
1650
+ if (ownership !== "live") {
1580
1651
  if (!agent.settled)
1581
1652
  return UNKNOWN;
1582
1653
  return GONE;
@@ -1594,6 +1665,28 @@ function idleConditionMet(timer, snapshot) {
1594
1665
  ? states.some((s) => s.gone || (s.idle && s.since != null && s.since >= timer.created_at))
1595
1666
  : states.length > 0 && states.every((s) => s.idle);
1596
1667
  }
1668
+ // An idle wake has no due_at, so its ceiling clock is the moment its condition was met (the watched
1669
+ // worker's state_changed_at, latest for idle_all, earliest qualifying for idle_any), or max_wait_at
1670
+ // once it has timed out unmet. A met condition with no such moment (a gone worker) falls back to
1671
+ // first_held_at.
1672
+ function idleCeilingStart(timer, snapshot) {
1673
+ if (timer.kind !== "idle_any" && timer.kind !== "idle_all")
1674
+ return null;
1675
+ if (snapshot === null || !idleConditionMet(timer, snapshot)) {
1676
+ if (timer.max_wait_at === null)
1677
+ return null;
1678
+ const timedOut = stmt(`SELECT ? <= datetime('now') AS out`).get(timer.max_wait_at);
1679
+ return timedOut.out ? timer.max_wait_at : null;
1680
+ }
1681
+ const states = watchedStates(timer, snapshot);
1682
+ const sinces = states
1683
+ .filter((s) => (timer.kind === "idle_any" ? s.idle && !s.gone && s.since != null && s.since >= timer.created_at : true))
1684
+ .map((s) => s.since);
1685
+ const met = sinces.length > 0 && sinces.every((v) => v != null) ? sinces : null;
1686
+ if (met !== null)
1687
+ return timer.kind === "idle_any" ? met.reduce((a, b) => (a < b ? a : b)) : met.reduce((a, b) => (a > b ? a : b));
1688
+ return timer.held_at != null ? timer.first_held_at : null;
1689
+ }
1597
1690
  async function maybeFireIdle(timer, snapshot, now, choices) {
1598
1691
  const timedOut = timer.max_wait_at != null && timer.max_wait_at <= now;
1599
1692
  if (isStandingWatch(timer)) {
@@ -1606,7 +1699,7 @@ async function maybeFireIdle(timer, snapshot, now, choices) {
1606
1699
  const decision = deliverable(timer, snapshot, choices);
1607
1700
  if (!decision.ok || !claimOneShot(timer))
1608
1701
  return false;
1609
- await deliver(timer, STANDING_EXPIRED_NOTE, choices, decision.typedSeen, decision.firstHeldAt);
1702
+ await deliver(timer, STANDING_EXPIRED_NOTE, choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
1610
1703
  return true;
1611
1704
  });
1612
1705
  }
@@ -1632,7 +1725,7 @@ async function maybeFireIdle(timer, snapshot, now, choices) {
1632
1725
  const decision = deliverable(timer, snapshot, choices);
1633
1726
  if (!decision.ok || !claimOneShot(timer))
1634
1727
  return false;
1635
- await deliver(timer, timedOut && !idleMet ? "max wait reached" : "", choices, decision.typedSeen, decision.firstHeldAt);
1728
+ await deliver(timer, timedOut && !idleMet ? "max wait reached" : "", choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
1636
1729
  return true;
1637
1730
  });
1638
1731
  }
@@ -1662,7 +1755,7 @@ async function maybeFireLeadWatch(timer, sub, snapshot, now, choices) {
1662
1755
  if (result.kind === "invalid") {
1663
1756
  bestEffortRun("UPDATE lead_idle_subscriptions SET terminal_reason = ? WHERE wake_id = ?", result.reason, timer.id);
1664
1757
  }
1665
- await deliver(timer, note, choices, decision.typedSeen, decision.firstHeldAt);
1758
+ await deliver(timer, note, choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
1666
1759
  return true;
1667
1760
  });
1668
1761
  }
@@ -1700,7 +1793,7 @@ function watchedTail(timer) {
1700
1793
  return "";
1701
1794
  const shown = [];
1702
1795
  for (const id of ids.slice(0, TAIL_AGENTS)) {
1703
- const agent = stmt("SELECT name, tmux_target, tmux_socket, agent_state, state_changed_at, status, actor_id, command, kind, cwd, session_id, transcript_path FROM agents WHERE id = ?").get(id);
1796
+ const agent = stmt("SELECT name, tmux_target, tmux_socket, pane_pid, agent_state, state_changed_at, status, actor_id, command, kind, cwd, session_id, transcript_path FROM agents WHERE id = ?").get(id);
1704
1797
  if (!agent)
1705
1798
  continue;
1706
1799
  const context = contextClause(agent);
@@ -1713,6 +1806,12 @@ function watchedTail(timer) {
1713
1806
  "socket than this process, so it cannot honestly be read from here.");
1714
1807
  continue;
1715
1808
  }
1809
+ const ownership = rowOwnership(agent);
1810
+ if (ownership !== "live") {
1811
+ shown.push(`${agent.name} (hive state now: ${stateNowClause(agent)}${context}): its pane ownership reads ${ownership}, ` +
1812
+ "so its terminal was not read.");
1813
+ continue;
1814
+ }
1716
1815
  let tail = "";
1717
1816
  try {
1718
1817
  const view = paneClassifierFor(agent.command)?.trailerView(agent.tmux_target) ?? null;
@@ -1786,7 +1885,14 @@ export function shortRenderForLeadDelivery(timer) {
1786
1885
  return null;
1787
1886
  return standingNoticeBodyShort(timer, candidates, timer.id);
1788
1887
  }
1789
- async function deliver(timer, note, choices, typedSeen, firstHeldAt) {
1888
+ // The claim already happened, so this records why and never re-arms or replays the wake.
1889
+ function recordLostAfterClaim(timer, pasted, why) {
1890
+ bestEffortRun(`UPDATE wakes SET held_reason = ?,
1891
+ typed_at = CASE WHEN ? = 1 THEN strftime('%Y-%m-%d %H:%M:%f', 'now') ELSE typed_at END,
1892
+ cancelled_at = CASE WHEN repeat_every_ms IS NULL THEN datetime('now') ELSE cancelled_at END
1893
+ WHERE id = ?`, HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM + why + (pasted ? " The body was pasted; no Enter was sent." : ""), pasted ? 1 : 0, timer.id);
1894
+ }
1895
+ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner) {
1790
1896
  const tail = watchedTail(timer);
1791
1897
  const prefix = `[hive wake #${timer.id}${note ? `, ${note}` : ""}] `;
1792
1898
  const body = (isLeadActorId(timer.deliver_actor) ? shortRenderForLeadDelivery(timer) : null) ?? timer.body;
@@ -1803,8 +1909,19 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt) {
1803
1909
  WHERE id = ?`, typedBusy, typedSeen, firstHeldAt, timer.id);
1804
1910
  const box = cacheEntry(timer.deliver_pane, choices).box;
1805
1911
  const strandedTextWouldHold = box !== undefined && box !== null && box.state !== "unknown";
1912
+ let pasted = false;
1806
1913
  try {
1807
- await sendText(timer.deliver_pane, prefix + body + noticeStalenessNote(timer) + tail, true, strandedTextWouldHold ? recordTyped : undefined);
1914
+ await sendText(timer.deliver_pane, prefix + body + noticeStalenessNote(timer) + tail, true, () => {
1915
+ pasted = true;
1916
+ if (strandedTextWouldHold)
1917
+ recordTyped();
1918
+ }, undefined, owner ? () => requireStillOwned(owner.rowId, owner.identity, `wake #${timer.id} delivery`) : undefined);
1919
+ }
1920
+ catch (err) {
1921
+ if (!(err instanceof PaneOwnershipLost))
1922
+ throw err;
1923
+ recordLostAfterClaim(timer, pasted, err.message);
1924
+ return;
1808
1925
  }
1809
1926
  finally {
1810
1927
  forgetPaneAnswers(timer.deliver_pane, choices);
package/dist/spawn.js CHANGED
@@ -4,7 +4,7 @@ import { preservedRolloutPath, reapCodexHome } from "./codexHome.js";
4
4
  import { getProject } from "./context.js";
5
5
  import { dataDir, db } from "./db.js";
6
6
  import { loadProjectYml } from "./projectYml.js";
7
- import { applyLayout, claimInitialWindow, createWindow, DEFAULT_LAYOUT, ensureSession, crossServerRefusal, findProcessesWindow, findProjectWindow, isPaneTarget, panePid, paneWindow, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, retainOnExitArgs, rowLive, sessionName, setPaneTitle, makeProcessesWindow, tmux, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, windowOwner, windowTitle, } from "./tmux.js";
7
+ import { applyLayout, claimInitialWindow, createWindow, DEFAULT_LAYOUT, ensureSession, crossServerRefusal, findProcessesWindow, findProjectWindow, isPaneTarget, panePidForRecord, paneWindow, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, retainOnExitArgs, rowLive, sessionName, setPaneTitle, makeProcessesWindow, tmux, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, windowOwner, windowTitle, rowOwnership, } from "./tmux.js";
8
8
  export function withWindowClaim(claim) {
9
9
  if (db.inTransaction) {
10
10
  throw new Error("withWindowClaim was called from inside an already-open transaction. better-sqlite3 nests a " +
@@ -51,10 +51,18 @@ export function agentIdentityEnv(actorId, name, projectPath) {
51
51
  HIVE_LEAD: "",
52
52
  };
53
53
  }
54
- function recordPane(agentId, target, socket) {
54
+ function recordPane(agentId, target, socket, pid) {
55
55
  return (db
56
56
  .prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ? WHERE id = ? AND status = 'running'")
57
- .run(target, socket, panePid(target), agentId).changes > 0);
57
+ .run(target, socket, pid, agentId).changes > 0);
58
+ }
59
+ function readPidOrDiscard(target) {
60
+ const pid = panePidForRecord(target);
61
+ if (pid !== "")
62
+ return pid;
63
+ discardOrphanedPane(target);
64
+ throw new Error(`tmux did not report a process id for the new pane ${target} (asked twice), so it was discarded rather than ` +
65
+ "recorded: a row with no pane pid can never be verified as owning its pane. Retry.");
58
66
  }
59
67
  // Two callers, one meaning: whatever is left in the window re-tiles to the layout it was arranged
60
68
  // with. agent_close kills a worker's pane through this; stopProcess (src/processes.ts) is the only
@@ -186,8 +194,9 @@ export function launchAgent(spec) {
186
194
  const envFlags = buildEnvFlags(env);
187
195
  const title = windowTitle(spec.projectName, spec.name);
188
196
  const { target, landedInProjectId, layoutApplied, inProcessesWindow } = placeAgentPane(session, spec, envFlags, commandString, title);
197
+ const pid = readPidOrDiscard(target);
189
198
  paneUp = true;
190
- if (!recordPane(agentId, target, socket)) {
199
+ if (!recordPane(agentId, target, socket, pid)) {
191
200
  discardOrphanedPane(target);
192
201
  throw paneRacedRetirement(agentId);
193
202
  }
@@ -261,8 +270,9 @@ export function resumeAgent(spec) {
261
270
  const session = sessionName();
262
271
  const title = windowTitle(spec.projectName, spec.name);
263
272
  const { target, landedInProjectId } = placeAgentPane(session, spec, envFlags, spec.commandString, title);
273
+ const pid = readPidOrDiscard(target);
264
274
  paneUp = true;
265
- if (!recordPane(spec.agentId, target, socket)) {
275
+ if (!recordPane(spec.agentId, target, socket, pid)) {
266
276
  discardOrphanedPane(target);
267
277
  throw paneRacedRetirement(spec.agentId);
268
278
  }
@@ -328,11 +338,51 @@ export function branchAt(cwd) {
328
338
  return branch;
329
339
  return git(["rev-parse", "--short", "HEAD"]) ?? "";
330
340
  }
331
- export function parkAgentRow(agentId, expectedTmuxTarget, branch) {
332
- return db
333
- .prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now'), parked_at = datetime('now'), " +
334
- "parked_branch = ? WHERE id = ? AND status = 'running' AND tmux_target = ? RETURNING parked_at")
335
- .get(branch, agentId, expectedTmuxTarget)?.parked_at;
341
+ export function parkAgentRow(agentId, expected, branch) {
342
+ const park = "UPDATE agents SET status = 'closed', closed_at = datetime('now'), parked_at = datetime('now'), " +
343
+ "parked_branch = ? WHERE id = ? AND status = 'running' AND tmux_target = ?";
344
+ const row = typeof expected === "string"
345
+ ? db.prepare(`${park} RETURNING parked_at`).get(branch, agentId, expected)
346
+ : db
347
+ .prepare(`${park} AND tmux_socket = ? AND pane_pid = ? RETURNING parked_at`)
348
+ .get(branch, agentId, expected.tmux_target, expected.tmux_socket, expected.pane_pid);
349
+ return row?.parked_at;
350
+ }
351
+ export const paneIdentity = (row) => ({
352
+ tmux_target: row.tmux_target,
353
+ tmux_socket: row.tmux_socket,
354
+ pane_pid: row.pane_pid,
355
+ });
356
+ // "changed" means the row is no longer running or no longer carries the identity the caller read.
357
+ export function currentOwnership(agentId, expected, snapshot) {
358
+ const row = db
359
+ .prepare("SELECT status, tmux_target, tmux_socket, pane_pid FROM agents WHERE id = ?")
360
+ .get(agentId);
361
+ if (!row ||
362
+ row.status !== "running" ||
363
+ row.tmux_target !== expected.tmux_target ||
364
+ row.tmux_socket !== expected.tmux_socket ||
365
+ row.pane_pid !== expected.pane_pid) {
366
+ return "changed";
367
+ }
368
+ return rowOwnership(row, snapshot);
369
+ }
370
+ export class PaneOwnershipLost extends Error {
371
+ agentId;
372
+ ownership;
373
+ constructor(agentId, ownership, action) {
374
+ super(`[pane-ownership-lost] ${action} stopped before touching agent ${agentId}'s pane: ` +
375
+ (ownership === "changed"
376
+ ? "its row was closed or recorded a different pane since this call read it."
377
+ : `its pane ownership now reads ${ownership}, so the pane may belong to another process.`));
378
+ this.agentId = agentId;
379
+ this.ownership = ownership;
380
+ }
381
+ }
382
+ export function requireStillOwned(agentId, expected, action) {
383
+ const ownership = currentOwnership(agentId, expected);
384
+ if (ownership !== "live")
385
+ throw new PaneOwnershipLost(agentId, ownership, action);
336
386
  }
337
387
  export const PARK_STAMP_CLEARED = "parked_at = '', parked_branch = ''";
338
388
  export function releaseParkRow(agentId) {
package/dist/tmux.js CHANGED
@@ -413,6 +413,10 @@ export function targetPid(target, snapshot) {
413
413
  export function panePid(target) {
414
414
  return targetLiveProbe(target).pid ?? "";
415
415
  }
416
+ // A row recorded with an empty pid reads unknown for life, so a pane hive just created gets one retry.
417
+ export function panePidForRecord(target) {
418
+ return panePid(target) || panePid(target);
419
+ }
416
420
  const DESTROY_READINESS_BOUND_MS = 200;
417
421
  function paneHasEstablishedProcess(pid) {
418
422
  try {
@@ -492,6 +496,14 @@ export function rowOwnership(row, snapshot) {
492
496
  return "unknown";
493
497
  return pid === row.pane_pid ? "live" : "reissued";
494
498
  }
499
+ // The one place a row's ownership is observed: a failed probe is its own answer, never unknown.
500
+ export function observeRowOwnership(row) {
501
+ const observes = !foreignSocket(row.tmux_socket) && row.tmux_target !== "";
502
+ const snapshot = observes ? liveTargets() : null;
503
+ if (observes && observationFailed(snapshot))
504
+ return "probe-failed";
505
+ return rowOwnership(row, snapshot);
506
+ }
495
507
  export function ownershipLiveness(ownership) {
496
508
  return ownership === "live" ? true : ownership === "unknown" ? null : false;
497
509
  }
@@ -1359,7 +1371,7 @@ export function findUnsafeControlChar(text, allowed) {
1359
1371
  }
1360
1372
  return null;
1361
1373
  }
1362
- export async function sendText(target, text, submit = true, onPasted, onBuffered) {
1374
+ export async function sendText(target, text, submit = true, onPasted, onBuffered, beforePaneWrite) {
1363
1375
  // tmux hands a pane its input in 1022-byte writes, and only paste-buffer -p
1364
1376
  // brackets them, so send-keys -l loses everything before the last write.
1365
1377
  // An empty text skips the pair: set-buffer creates no buffer for it, so
@@ -1371,6 +1383,7 @@ export async function sendText(target, text, submit = true, onPasted, onBuffered
1371
1383
  tmux("set-buffer", "-b", buffer, "--", text);
1372
1384
  onBuffered?.();
1373
1385
  try {
1386
+ beforePaneWrite?.();
1374
1387
  tmux("paste-buffer", "-d", "-p", "-b", buffer, "-t", target);
1375
1388
  }
1376
1389
  catch (err) {
@@ -1391,6 +1404,7 @@ export async function sendText(target, text, submit = true, onPasted, onBuffered
1391
1404
  }
1392
1405
  if (submit) {
1393
1406
  await sleep(ENTER_DELAY_MS);
1407
+ beforePaneWrite?.();
1394
1408
  tmux("send-keys", "-t", target, "Enter");
1395
1409
  }
1396
1410
  }