toolroll 0.9.98 → 0.9.100

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/acp-check.js +4 -2
  3. package/dist/agent-state.js +2 -1
  4. package/dist/agents-board.js +3 -2
  5. package/dist/agents-live.js +3 -2
  6. package/dist/approval-authority.d.ts +2 -1
  7. package/dist/approval-authority.js +2 -1
  8. package/dist/chat-ask.js +4 -2
  9. package/dist/chat-core.js +4 -2
  10. package/dist/chat-decide.js +1 -2
  11. package/dist/chat-delivery-state.d.ts +29 -6
  12. package/dist/chat-delivery-state.js +43 -13
  13. package/dist/chat-delivery.js +12 -5
  14. package/dist/chat-flow.js +15 -13
  15. package/dist/chat-live.js +5 -2
  16. package/dist/chat-message-states.d.ts +75 -0
  17. package/dist/chat-message-states.js +74 -0
  18. package/dist/chat-messages.d.ts +10 -4
  19. package/dist/chat-messages.js +12 -7
  20. package/dist/check-failure.js +2 -1
  21. package/dist/check-report.js +1 -1
  22. package/dist/checkout-cleanup.js +2 -1
  23. package/dist/claim.d.ts +40 -2
  24. package/dist/claim.js +92 -19
  25. package/dist/contracts/chat-tables.js +3 -0
  26. package/dist/coordinator.js +3 -3
  27. package/dist/demo.js +7 -2
  28. package/dist/deploy-phase-data.json +21 -0
  29. package/dist/deploy-phase.d.ts +37 -0
  30. package/dist/deploy-phase.js +64 -0
  31. package/dist/deploy-rescue.d.ts +2 -1
  32. package/dist/deploy-rescue.js +6 -7
  33. package/dist/discord-chat.js +2 -3
  34. package/dist/dispatch.js +2 -1
  35. package/dist/dispose.d.ts +4 -1
  36. package/dist/dispose.js +2 -1
  37. package/dist/fallback.js +3 -2
  38. package/dist/fast-start.js +2 -2
  39. package/dist/monitoring.js +2 -1
  40. package/dist/operate.js +75 -36
  41. package/dist/plane-review.js +2 -1
  42. package/dist/project-knowledge.d.ts +3 -3
  43. package/dist/release-adapters.js +30 -14
  44. package/dist/release-authority.d.ts +9 -0
  45. package/dist/release-authority.js +13 -0
  46. package/dist/release-auto.js +2 -3
  47. package/dist/release-gate.d.ts +3 -3
  48. package/dist/release-gate.js +8 -5
  49. package/dist/release-next.d.ts +3 -3
  50. package/dist/release-next.js +13 -16
  51. package/dist/release-view.js +2 -1
  52. package/dist/release.js +18 -30
  53. package/dist/result-shots.js +1 -1
  54. package/dist/route-results.js +2 -1
  55. package/dist/run-ending.d.ts +21 -0
  56. package/dist/run-ending.js +19 -0
  57. package/dist/scope.js +2 -2
  58. package/dist/server/render-results.js +3 -2
  59. package/dist/server/render-tasks.js +3 -2
  60. package/dist/setup-guide.d.ts +2 -2
  61. package/dist/slack-chat.js +2 -3
  62. package/dist/staged-runtimes.js +2 -1
  63. package/dist/store.d.ts +61 -2
  64. package/dist/store.js +127 -77
  65. package/dist/subagent-question.js +8 -6
  66. package/dist/surface.js +2 -2
  67. package/dist/task-moves.d.ts +90 -0
  68. package/dist/task-moves.js +68 -0
  69. package/dist/teams-chat.js +2 -2
  70. package/dist/telegram-flow.d.ts +1 -1
  71. package/dist/telegram-flow.js +1 -1
  72. package/dist/telegram-lead.d.ts +1 -2
  73. package/dist/telegram-lead.js +18 -23
  74. package/dist/telegram-progress.js +2 -1
  75. package/dist/telegram.d.ts +0 -1
  76. package/dist/telegram.js +6 -7
  77. package/dist/vanished-retry.js +3 -2
  78. package/dist/workspace-ui.js +2 -1
  79. package/dist/worktree.js +3 -2
  80. package/package.json +1 -1
@@ -3,7 +3,7 @@
3
3
  import { resultShotsPruned } from "./result-shots.js";
4
4
  import { chatQuestionButtons } from "./subagent-question.js";
5
5
  import { chatAskButtons } from "./chat-ask.js";
6
- import { ChatDeliveryError, ChatState, chatHash, partContent } from "./chat-delivery-state.js";
6
+ import { ChatDeliveryError, ChatState, chatHash, chatRetryAt, partContent } from "./chat-delivery-state.js";
7
7
  import { isLiveMessage, liveSpacingOf, underCooldown } from "./chat-live.js";
8
8
  import { channelAccess, chatObject as object, heldForWatch, planChatNotifications, planRoomMessages, processChatEvent, splitChatText } from "./chat-delivery.js";
9
9
  import { PLATFORM_LIMITS } from "./text-limits.js";
@@ -260,7 +260,7 @@ export async function deliverTeamsPart(options) {
260
260
  return true;
261
261
  }
262
262
  const problem = error instanceof TeamsError ? error : new TeamsError("Teams delivery failed", 15_000, true);
263
- state.partFailed(row.id, { problem: problem.message, until: new Date(now.getTime() + problem.retryMs).toISOString(), uncertain: problem.uncertain }, 20);
263
+ state.partFailed(row.id, { problem: problem.message, until: chatRetryAt(now, problem.retryMs, row.attempts), uncertain: problem.uncertain });
264
264
  return true;
265
265
  }
266
266
  }
@@ -1,7 +1,7 @@
1
1
  import { type FlowChoiceVisit, type FlowSendContent } from "./flow-send.js";
2
2
  import { type FlowStage } from "./flows.js";
3
3
  import { type InlineButton } from "./telegram-lead.js";
4
- import type { FlowCardRow, FlowRow, Store, TelegramBinding, TelegramFlowAction, TelegramFlowChoice, TelegramFlowPrompt } from "./store.js";
4
+ import { type FlowCardRow, type FlowRow, type Store, type TelegramBinding, type TelegramFlowAction, type TelegramFlowChoice, type TelegramFlowPrompt } from "./store.js";
5
5
  /** A flow decision's notice: one per visit, and again (`:ask:<ms>`) when the owner asks the lead to approve it. */
6
6
  export declare const FLOW_DECIDE_KEY: RegExp;
7
7
  type Waiting = {
@@ -26,6 +26,7 @@ import { decideFlowCard, draftFor, flowCardHref, flowDefinitionOf } from "./flow
26
26
  import { chooseFlowCard, flowChoiceAt, flowPersonOf, flowSendPaths, readFlowSend } from "./flow-send.js";
27
27
  import { deciderOf, FLOW_END, replyTarget } from "./flows.js";
28
28
  import { phoneLinkButton } from "./telegram-lead.js";
29
+ import { CONFIRM_TTL_MS } from "./store.js";
29
30
  import { telegramButton } from "./contracts/telegram-callback.js";
30
31
  /** A flow decision's notice: one per visit, and again (`:ask:<ms>`) when the owner asks the lead to approve it. */
31
32
  export const FLOW_DECIDE_KEY = /^flow-decide:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})(?::ask:[0-9]{1,15})?$/;
@@ -132,7 +133,6 @@ function freshButtons(store, binding, waiting, now) {
132
133
  const minted = flowButtons(store, binding, waiting, now);
133
134
  return { keyboard: minted.keyboard.map(row => row.flatMap(one => "callback_data" in one && typeof one.callback_data === "string" ? [{ label: one.text, token: one.callback_data }] : [])), place: minted.tokens };
134
135
  }
135
- const CONFIRM_TTL_MS = 10 * 60_000;
136
136
  const FLOW_QUESTION = /\n\nApprove “[^\n]*”\? [^\n]*$/;
137
137
  /** Where a card is now, for a message whose buttons no longer act: named and linked only when it is in one of the
138
138
  * viewer's projects, and linked only while it still waits on someone. Never a live button. */
@@ -13,9 +13,8 @@ export declare const CONVERSATION_MAX_AGE_MS: number;
13
13
  /** Waiting for the lead itself (it is answering, or earlier messages wait in the person's lead queue) is bounded longer:
14
14
  * two hours from the message's arrival, never reset as it moves from waiting behind the queue to waiting on a turn. */
15
15
  export declare const CONVERSATION_WAIT_MAX_AGE_MS: number;
16
- /** A card's buttons and an armed irreversible challenge live this long. */
16
+ /** A card's buttons live this long; its armed Yes and Cancel last CONFIRM_TTL_MS. */
17
17
  export declare const CARD_TTL_MS: number;
18
- export declare const CHALLENGE_TTL_MS: number;
19
18
  /** Telegram's own message ceiling, with room for our part headers (the same bound telegram.ts splits at). */
20
19
  export declare const PART_CAP = 3900;
21
20
  export type TelegramConversationOptions = {
@@ -29,7 +29,7 @@ import { createHash, randomBytes } from "node:crypto";
29
29
  import { leadFailureText, runLeadTurn } from "./lead.js";
30
30
  import { confirmLeadProposal, confirmTypedStart, dismissLeadProposal, TYPED_START_STARTED } from "./lead-doors.js";
31
31
  import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
32
- import { LEAD_ASK_OTHER } from "./store.js";
32
+ import { CONFIRM_TTL_MS, LEAD_ASK_OTHER } from "./store.js";
33
33
  import { phoneText } from "./telegram-status.js";
34
34
  import { shapeReplyParts, telegramReply } from "./reply-shape.js";
35
35
  import { createTelegramLiveDraft, forgetWaitingDraft, telegramAck, telegramWaitingDraft } from "./telegram-live.js";
@@ -37,6 +37,7 @@ import { TeamLeads } from "./team-leads.js";
37
37
  import { chatResultHref } from "./chat-controls.js";
38
38
  import { resultImageFileName, safeResultImageCaption, verifyResultImage } from "./chat-evidence.js";
39
39
  import { telegramButton } from "./contracts/telegram-callback.js";
40
+ import { chatOutcome, isAdmittedWait, isLeadWait } from "./chat-message-states.js";
40
41
  /** How long one claimed turn may go without a heartbeat before another poller may take it over. */
41
42
  export const CONVERSATION_CLAIM_MS = 2 * 60_000;
42
43
  /** A busy engine (one turn at a time) defers a queued message this long, up to the age bound below. */
@@ -45,11 +46,8 @@ export const CONVERSATION_MAX_AGE_MS = 10 * 60_000;
45
46
  /** Waiting for the lead itself (it is answering, or earlier messages wait in the person's lead queue) is bounded longer:
46
47
  * two hours from the message's arrival, never reset as it moves from waiting behind the queue to waiting on a turn. */
47
48
  export const CONVERSATION_WAIT_MAX_AGE_MS = 2 * 3_600_000;
48
- /** A message whose turn was admitted, waiting for the project records to deliver its answer (no age bound). */
49
- const ADMITTED_REGISTRY_WAIT = "registry-unavailable:admitted";
50
- /** A card's buttons and an armed irreversible challenge live this long. */
49
+ /** A card's buttons live this long; its armed Yes and Cancel last CONFIRM_TTL_MS. */
51
50
  export const CARD_TTL_MS = 24 * 3_600_000;
52
- export const CHALLENGE_TTL_MS = 10 * 60_000;
53
51
  /** Telegram's own message ceiling, with room for our part headers (the same bound telegram.ts splits at). */
54
52
  export const PART_CAP = 3_900;
55
53
  // ---- the ceiling and the principal --------------------------------------------
@@ -261,8 +259,8 @@ export function applyProposalTap(store, binding, token, message, repos, options,
261
259
  const yes = randomBytes(16).toString("hex");
262
260
  const cancel = randomBytes(16).toString("hex");
263
261
  const placedOn = String(message.message_id);
264
- store.createTelegramProposalAction({ token: yes, binding: binding.id, proposal: proposal.id, phase: "yes", chatId: tapChatId, messageId: placedOn, ttlMs: CHALLENGE_TTL_MS }, now);
265
- store.createTelegramProposalAction({ token: cancel, binding: binding.id, proposal: proposal.id, phase: "cancel", chatId: tapChatId, messageId: placedOn, ttlMs: CHALLENGE_TTL_MS }, now);
262
+ store.createTelegramProposalAction({ token: yes, binding: binding.id, proposal: proposal.id, phase: "yes", chatId: tapChatId, messageId: placedOn, ttlMs: CONFIRM_TTL_MS }, now);
263
+ store.createTelegramProposalAction({ token: cancel, binding: binding.id, proposal: proposal.id, phase: "cancel", chatId: tapChatId, messageId: placedOn, ttlMs: CONFIRM_TTL_MS }, now);
266
264
  const body = preview.text.split("\n\nConfirm or Dismiss below")[0];
267
265
  if (challenged) {
268
266
  ack("confirm it");
@@ -358,8 +356,6 @@ export async function processTelegramConversations(args) {
358
356
  }
359
357
  class PlanRefused extends Error {
360
358
  }
361
- /** The outcome a planned reply waits under until the bridge's cycle sends it. */
362
- const ANSWERED_SENDING = "answered:sending";
363
359
  /**
364
360
  * One claimed message, in order of what is already known about it:
365
361
  *
@@ -401,11 +397,10 @@ async function runTelegramConversation(row, args) {
401
397
  const requeue = async (outcome) => {
402
398
  // A turn already admitted for it waits for that turn's own outcome (bounded by the turn's deadline); waiting for the
403
399
  // lead gets two hours from arrival; anything else, ten minutes.
404
- const limit = outcome === "replayed:running" || outcome === ADMITTED_REGISTRY_WAIT ? Number.POSITIVE_INFINITY
405
- : outcome === "busy" || outcome === "backlog" ? CONVERSATION_WAIT_MAX_AGE_MS : CONVERSATION_MAX_AGE_MS;
400
+ const limit = isAdmittedWait(outcome) ? Number.POSITIVE_INFINITY : isLeadWait(outcome) ? CONVERSATION_WAIT_MAX_AGE_MS : CONVERSATION_MAX_AGE_MS;
406
401
  if (ageMs > limit) {
407
402
  await notify(couldNotAnswerText(outcome === "registry-unavailable" ? "your projects couldn't be read" : "the assistant stayed busy with another message"));
408
- finish({ state: "failed", outcome: `${outcome}:gave-up` });
403
+ finish({ state: "failed", outcome: chatOutcome.gaveUp(outcome) });
409
404
  report.refused++;
410
405
  return;
411
406
  }
@@ -413,7 +408,7 @@ async function runTelegramConversation(row, args) {
413
408
  if (outcome === "backlog" && row.outcome !== "backlog")
414
409
  await notify("Queued behind your earlier messages. It goes to the lead once they're answered.");
415
410
  // Waiting for the lead: a draft that says so, refreshed while it waits (never over this chat's running turn).
416
- if (outcome === "busy" || outcome === "backlog") {
411
+ if (isLeadWait(outcome)) {
417
412
  await telegramWaitingDraft({ store, botId, transport, clock, chatId: row.chatId, binding: row.binding, draftId: row.id, allowed: () => stillPaired() && claimed(), ...(options.liveTiming === undefined ? {} : { timing: options.liveTiming }) });
418
413
  }
419
414
  finish({ state: "queued", outcome, nextAttemptAt: new Date(clock().getTime() + CONVERSATION_RETRY_MS).toISOString() });
@@ -483,7 +478,7 @@ async function runTelegramConversation(row, args) {
483
478
  if (now.getTime() - Date.parse(part.createdAt) > DELIVERY_MAX_AGE_MS) {
484
479
  report.problems.push(`telegram chat reply for update ${row.updateId} was not sent within a day: ${part.lastError ?? "unsent"}`);
485
480
  await notify(couldNotAnswerText("my reply couldn't be sent for a day"));
486
- finish({ state: "failed", outcome: `unsent:gave-up:${part.lastError ?? "unsent"}` });
481
+ finish({ state: "failed", outcome: chatOutcome.unsentGaveUp(part.lastError) });
487
482
  report.refused++;
488
483
  return;
489
484
  }
@@ -510,7 +505,7 @@ async function runTelegramConversation(row, args) {
510
505
  if (problem !== null) {
511
506
  report.problems.push(`telegram chat reply for update ${row.updateId} was not sent: ${problem}`);
512
507
  await notify(`${problem.charAt(0).toUpperCase()}${problem.slice(1)}, so the assistant's reply was not sent. Nothing was changed. Send your message again if you still want it.`);
513
- finish({ state: "failed", outcome: `unsent:${problem}` });
508
+ finish({ state: "failed", outcome: chatOutcome.unsent(problem) });
514
509
  report.refused++;
515
510
  return;
516
511
  }
@@ -708,7 +703,7 @@ async function runTelegramConversation(row, args) {
708
703
  }
709
704
  if (turn.state !== "answered") {
710
705
  await notify(leadFailureText(turn.failureReason, store.leadTurnCostUnconfirmed(turn.id), turn.failureDetail ?? null));
711
- finish({ state: "failed", outcome: `replayed:${turn.failureReason ?? "failed"}` });
706
+ finish({ state: "failed", outcome: chatOutcome.replayedFailed(turn.failureReason) });
712
707
  report.refused++;
713
708
  return;
714
709
  }
@@ -724,11 +719,11 @@ async function runTelegramConversation(row, args) {
724
719
  await deliver(outcomeWord);
725
720
  return;
726
721
  }
727
- finish({ state: "queued", outcome: outcomeWord === "answered" ? ANSWERED_SENDING : "delivering", nextAttemptAt: clock().toISOString() });
722
+ finish({ state: "queued", outcome: outcomeWord === "answered" ? "answered:sending" : "delivering", nextAttemptAt: clock().toISOString() });
728
723
  };
729
724
  // 2. Parts already planned: the turn answered; only the sending remains.
730
725
  if (store.listTelegramConversationParts(row.id).length > 0) {
731
- await deliver(row.outcome === ANSWERED_SENDING ? "answered" : "replayed");
726
+ await deliver(row.outcome === "answered:sending" ? "answered" : "replayed");
732
727
  return;
733
728
  }
734
729
  // 3. A turn already admitted for it and still working: wait for that turn's own outcome. Nothing here needs the
@@ -755,7 +750,7 @@ async function runTelegramConversation(row, args) {
755
750
  // A turn already took it (its receipt is in the bound session): its answer exists or will, and is delivered once
756
751
  // the projects can be read again — never abandoned to the outage.
757
752
  const admitted = row.session !== null && store.leadRequestReceipt(row.session, row.request) !== null;
758
- await requeue(admitted ? ADMITTED_REGISTRY_WAIT : "registry-unavailable");
753
+ await requeue(admitted ? "registry-unavailable:admitted" : "registry-unavailable");
759
754
  return;
760
755
  }
761
756
  const repos = telegramConversationRepos(store, binding.approver, registry);
@@ -826,7 +821,7 @@ async function runTelegramConversation(row, args) {
826
821
  }
827
822
  // It waited (for the lead, or behind earlier messages), was never admitted, and is past the wait from its arrival:
828
823
  // it is never sent this late, even when the lead is free now.
829
- if ((row.outcome === "busy" || row.outcome === "backlog") && ageMs > CONVERSATION_WAIT_MAX_AGE_MS) {
824
+ if (isLeadWait(row.outcome) && ageMs > CONVERSATION_WAIT_MAX_AGE_MS) {
830
825
  await requeue(row.outcome);
831
826
  return;
832
827
  }
@@ -839,7 +834,7 @@ async function runTelegramConversation(row, args) {
839
834
  if (!resolved.ok) {
840
835
  if (resolved.said !== null)
841
836
  await notify(resolved.said);
842
- finish({ state: "failed", outcome: `refused:${resolved.reason}` });
837
+ finish({ state: "failed", outcome: chatOutcome.refused(resolved.reason) });
843
838
  report.refused++;
844
839
  return;
845
840
  }
@@ -924,7 +919,7 @@ async function runTelegramConversation(row, args) {
924
919
  return;
925
920
  }
926
921
  await notify(phoneText(outcome.message, 1_000));
927
- finish({ state: "failed", outcome: `refused:${outcome.refused}` });
922
+ finish({ state: "failed", outcome: chatOutcome.refused(outcome.refused) });
928
923
  report.refused++;
929
924
  return;
930
925
  }
@@ -932,7 +927,7 @@ async function runTelegramConversation(row, args) {
932
927
  // Truthful: what failed, that nothing was kept, and that a new message
933
928
  // is a new turn. A failed turn's drafts are already gone.
934
929
  await notify(phoneText(outcome.message, 1_000));
935
- finish({ state: "failed", outcome: `failed:${outcome.failed}` });
930
+ finish({ state: "failed", outcome: chatOutcome.failed(outcome.failed) });
936
931
  report.refused++;
937
932
  return;
938
933
  }
@@ -5,6 +5,7 @@
5
5
  * once the lead or a person has marked that exact result. Slack, Discord and
6
6
  * Teams send this same card. */
7
7
  import { machineChecked } from "./machine-check.js";
8
+ import { wasInterrupted } from "./run-ending.js";
8
9
  import { assignmentOf } from "./assignment.js";
9
10
  import { chatResultHref, chatControlHref } from "./chat-controls.js";
10
11
  import { phoneText, projectLabel } from "./telegram-status.js";
@@ -72,7 +73,7 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
72
73
  else if (run.outcome === "parked") {
73
74
  facts = { stage: "needs-you", need: "answer" };
74
75
  }
75
- else if (run.outcome === "interrupted" || run.reason === "interrupted") {
76
+ else if (wasInterrupted(run)) {
76
77
  facts = { stage: "stopped", reason: "The attempt was interrupted. Inspect the saved work, then resume." };
77
78
  }
78
79
  else {
@@ -24,7 +24,6 @@ export type TelegramReadProjects = () => Promise<readonly string[]>;
24
24
  /** The environment name — and therefore the name the builder strips from agents (both names; see names.ts). */
25
25
  export { TELEGRAM_TOKEN_ENV as TOKEN_ENV, TELEGRAM_TOKEN_ENVS as TOKEN_ENVS } from "./names.js";
26
26
  export declare const PAIRING_TTL_MS: number;
27
- export declare const CONFIRM_TTL_MS: number;
28
27
  export declare const BRIDGE_LEASE_MS: number;
29
28
  export declare const DELIVERY_CLAIM_MS: number;
30
29
  /** Where Telegram pushes this bot's updates: under the public hooks address (Tailscale Funnel covers /hooks). */
package/dist/telegram.js CHANGED
@@ -30,7 +30,7 @@ import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs";
30
30
  import { join } from "node:path";
31
31
  import { closedQuestionWords, validateNote } from "./decision.js";
32
32
  import { isShotsKind, resultShotsFor } from "./result-shots.js";
33
- import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_REASONS } from "./store.js";
33
+ import { CONFIRM_TTL_MS, isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_REASONS } from "./store.js";
34
34
  import { telegramProgressCard } from "./telegram-progress.js";
35
35
  import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
36
36
  import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
@@ -59,7 +59,6 @@ export { TELEGRAM_TOKEN_ENV as TOKEN_ENV, TELEGRAM_TOKEN_ENVS as TOKEN_ENVS } fr
59
59
  /** BotFather's shape: numeric bot id, colon, secret. */
60
60
  const TOKEN_SHAPE = /^(\d+):[A-Za-z0-9_-]{20,}$/;
61
61
  export const PAIRING_TTL_MS = 10 * 60_000;
62
- export const CONFIRM_TTL_MS = 10 * 60_000;
63
62
  export const BRIDGE_LEASE_MS = 2 * 60_000;
64
63
  export const DELIVERY_CLAIM_MS = 2 * 60_000;
65
64
  /** Telegram's own message ceiling, with room for our part headers. */
@@ -2575,7 +2574,7 @@ function applyCallback(context, update, effects) {
2575
2574
  const draft = store.liveNoteDraft(binding.id, decision.id, clock());
2576
2575
  const digest = draft === null ? undefined : noteDigestOf(draft.note);
2577
2576
  if (draft !== null)
2578
- store.setNoteDraftState(draft.id, "armed");
2577
+ store.setNoteDraftState(draft.id, ["pending", "armed"], "armed");
2579
2578
  store.createTelegramAction({ token: confirm, binding: binding.id, decision: decision.id, optionId: option.id, phase: "confirm", chatId: binding.chatId, messageId: placedOn, ttlMs: CONFIRM_TTL_MS, ...(digest === undefined ? {} : { noteDigest: digest }) }, clock());
2580
2579
  store.createTelegramAction({ token: cancel, binding: binding.id, decision: decision.id, optionId: option.id, phase: "cancel", chatId: binding.chatId, messageId: placedOn, ttlMs: CONFIRM_TTL_MS }, clock());
2581
2580
  ack("irreversible — confirm it");
@@ -2620,7 +2619,7 @@ function applyCallback(context, update, effects) {
2620
2619
  {
2621
2620
  const draft = store.liveNoteDraft(binding.id, decision.id, clock());
2622
2621
  if (draft !== null)
2623
- store.setNoteDraftState(draft.id, "discarded");
2622
+ store.setNoteDraftState(draft.id, ["pending", "armed"], "discarded");
2624
2623
  }
2625
2624
  const fresh = decision.options.map(option => ({ option, token: randomBytes(16).toString("hex") }));
2626
2625
  for (const { option, token: choose } of fresh) {
@@ -2642,7 +2641,7 @@ function answerNow(context, decision, choice, binding, ack, editText) {
2642
2641
  const answered = store.answerDecisionLocked({ id: decision.id, choice, by: binding.approver, via: "telegram", ...(draft === null ? {} : { note: draft.note }) }, clock());
2643
2642
  if (answered.ok) {
2644
2643
  if (draft !== null)
2645
- store.setNoteDraftState(draft.id, "consumed");
2644
+ store.setNoteDraftState(draft.id, ["pending", "armed"], "consumed");
2646
2645
  report.answered++;
2647
2646
  ack(`✓ ${choice}${draft === null ? "" : " — with your note"}`);
2648
2647
  editText(answeredText(store, answered.decision));
@@ -2650,7 +2649,7 @@ function answerNow(context, decision, choice, binding, ack, editText) {
2650
2649
  }
2651
2650
  if (answered.reason === "already-answered" || answered.reason === "closed") {
2652
2651
  if (draft !== null)
2653
- store.setNoteDraftState(draft.id, "discarded");
2652
+ store.setNoteDraftState(draft.id, ["pending", "armed"], "discarded");
2654
2653
  const settled = store.getDecision(decision.id);
2655
2654
  ack(`${settled?.supersededReason != null ? closedQuestionWords(settled.supersededReason) : `already answered: ${settled?.choice ?? "?"}`}${draft === null ? "" : " — your note did NOT travel"}`);
2656
2655
  if (settled !== null)
@@ -2702,7 +2701,7 @@ function expiredDraftGuard(store, binding, decisionId, now) {
2702
2701
  .get(binding.id, decisionId, now.toISOString());
2703
2702
  if (expired === undefined)
2704
2703
  return false;
2705
- store.setNoteDraftState(Number(expired["id"]), "discarded");
2704
+ store.setNoteDraftState(Number(expired["id"]), ["pending", "armed"], "discarded");
2706
2705
  return true;
2707
2706
  }
2708
2707
  /** Bridges in this process waiting out their push idle, by bot: a kept push ends the wait early. */
@@ -19,6 +19,7 @@
19
19
  import { authorityStandsSql } from "./approval-authority.js";
20
20
  import { stopFactOf } from "./store.js";
21
21
  import { refArchived } from "./task-archive.js";
22
+ import { moveTask } from "./task-moves.js";
22
23
  /** The automatic-retry limits, in one place. */
23
24
  export const RETRY_LIMIT = 3;
24
25
  export const RETRY_SPEND_TIMES = 2;
@@ -48,7 +49,7 @@ function approvalStands(store, taskRef, now) {
48
49
  WHERE task_scope.approved_digest = task_scope.digest AND task_scope.approved_at IS NOT NULL AND ${authorityStandsSql("task_scope", "task_ref.repo", "?")}`).get(taskRef, stamp, stamp, stamp, stamp) !== undefined;
49
50
  }
50
51
  function requeue(store, taskId, now) {
51
- return Number(store.handle.prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'").run(now.toISOString(), taskId).changes) > 0;
52
+ return moveTask(store.handle, taskId, "requeue", now);
52
53
  }
53
54
  function episodeOf(store, taskRef) {
54
55
  const row = store.handle.prepare("SELECT retries, reserved_microusd FROM retry_episode WHERE task_ref = ?").get(taskRef);
@@ -59,7 +60,7 @@ function stopFor(store, input) {
59
60
  store.handle.prepare(`INSERT INTO retry_episode (task_ref, retries, reserved_microusd, stopped, stopped_run, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
60
61
  ON CONFLICT (task_ref) DO UPDATE SET stopped = excluded.stopped, stopped_run = excluded.stopped_run, reserved_microusd = excluded.reserved_microusd, updated_at = excluded.updated_at`)
61
62
  .run(input.taskRef, input.retries, input.reservedMicrousd, input.why, input.run, stamp, stamp);
62
- store.handle.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ? AND state IN ('running', 'queued')").run(stamp, input.taskId);
63
+ moveTask(store.handle, input.taskId, "giveUp", input.now);
63
64
  store.enqueueNotification({
64
65
  source: { run: input.run },
65
66
  dedupeKey: `vanished:${input.run}`,
@@ -23,6 +23,7 @@ import { manualReviewOnly, plainReasonWords } from "./proof.js";
23
23
  import { CHECK_COMMAND_CHANGED, CHECK_UNREADABLE, OPEN_RESULT, approvedCheckOf, approvedCheckStateOf, plainReasonOf, stageOfCode, taskStatusOf, workToneOf } from "./task-status.js";
24
24
  import { whenUtc } from "./when-html.js";
25
25
  import { NEEDS } from "./needs-you.js";
26
+ import { wasInterrupted } from "./run-ending.js";
26
27
  import { html, postForm } from "./html.js";
27
28
  export const WORK_VIEWS = [
28
29
  { key: "all", label: "All", hint: "Every task in view, most urgent first.", empty: "Nothing is in progress. Describe work in chat or add a task, and it appears here." },
@@ -50,7 +51,7 @@ export function reviewFactsOf(retry, reviewerAlive, automaticPending = false) {
50
51
  attempts: retry.attempts.length,
51
52
  retriesRemaining: retry.retriesRemaining,
52
53
  latestReason: retry.latest?.reason ?? null,
53
- interrupted: retry.latest !== null && (retry.latest.outcome === "interrupted" || retry.latest.reason === "interrupted"),
54
+ interrupted: retry.latest !== null && wasInterrupted(retry.latest),
54
55
  queuedBy: retry.openRequest?.requestedBy ?? null,
55
56
  queuedOrigin: retry.openRequest?.origin ?? null,
56
57
  reviewerAlive: retry.live !== null && reviewerAlive(retry.live.runner),
package/dist/worktree.js CHANGED
@@ -34,6 +34,7 @@ import { HANDOFF_PREFIX, MAILBOX_SUFFIX, looksLikeProtocolFile, readMailbox } fr
34
34
  import { parseHandoff } from "./decision.js";
35
35
  import { diskBytes } from "./storage.js";
36
36
  import { markNeverIndex } from "./never-index.js";
37
+ import { endedInterrupted } from "./run-ending.js";
37
38
  import { checkoutGit, gitIncidentRecord, teardownGit } from "./teardown-git.js";
38
39
  /**
39
40
  * The note a lease leaves in the checkout, naming the process holding it.
@@ -598,7 +599,7 @@ export class WorktreePool {
598
599
  // failed/interrupted source required here.
599
600
  if (prior.parentRun !== null) {
600
601
  const source = this.store.getRun(prior.parentRun);
601
- if (source?.outcome === "failed" && source.reason === "interrupted") {
602
+ if (source !== null && endedInterrupted(source)) {
602
603
  return { runId: source.id, kind: "partial" };
603
604
  }
604
605
  }
@@ -619,7 +620,7 @@ export class WorktreePool {
619
620
  if (unhanded !== null && unhanded.outcome === "failed" && unhanded.reason === "no-handoff") {
620
621
  return brokeTree(attempt) || brokeTree(later) ? null : { runId: unhanded.id, kind: "partial" };
621
622
  }
622
- if (prior.outcome !== null && (prior.outcome !== "failed" || prior.reason !== "interrupted"))
623
+ if (prior.outcome !== null && !endedInterrupted(prior))
623
624
  return null;
624
625
  let names;
625
626
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.98",
3
+ "version": "0.9.100",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"