toolroll 0.9.97 → 0.9.99

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 (90) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/approval-authority.d.ts +10 -1
  3. package/dist/approval-authority.js +18 -2
  4. package/dist/browser/workspace.js +1 -1
  5. package/dist/browser-workspace.d.ts +4 -3
  6. package/dist/chat-actions.d.ts +40 -5
  7. package/dist/chat-actions.js +124 -13
  8. package/dist/chat-card.js +4 -1
  9. package/dist/chat-channel.d.ts +3 -0
  10. package/dist/chat-channel.js +24 -7
  11. package/dist/chat-decide.js +4 -2
  12. package/dist/chat-task-actions.js +1 -1
  13. package/dist/chat-terms.d.ts +8 -0
  14. package/dist/chat-terms.js +14 -0
  15. package/dist/chat-voice.js +1 -1
  16. package/dist/checkout-cleanup.js +2 -1
  17. package/dist/contracts/chat-actions.d.ts +6 -0
  18. package/dist/contracts/chat-actions.js +2 -0
  19. package/dist/contracts/cli.d.ts +6 -0
  20. package/dist/contracts/cli.js +3 -0
  21. package/dist/contracts/lead-tools.d.ts +17 -0
  22. package/dist/contracts/lead-tools.js +4 -1
  23. package/dist/contracts/store-json.d.ts +1 -0
  24. package/dist/deploy-phase-data.json +21 -0
  25. package/dist/deploy-phase.d.ts +37 -0
  26. package/dist/deploy-phase.js +64 -0
  27. package/dist/deploy-rescue.d.ts +2 -1
  28. package/dist/deploy-rescue.js +6 -7
  29. package/dist/desktop-update.d.ts +2 -2
  30. package/dist/fast-start.d.ts +83 -6
  31. package/dist/fast-start.js +112 -15
  32. package/dist/home-groups.d.ts +39 -0
  33. package/dist/home-groups.js +33 -0
  34. package/dist/lead-answer-eval.d.ts +7 -0
  35. package/dist/lead-answer-eval.js +6 -2
  36. package/dist/lead-capabilities.js +1 -1
  37. package/dist/lead-context.js +2 -2
  38. package/dist/lead-contract.d.ts +1 -1
  39. package/dist/lead-contract.js +10 -7
  40. package/dist/lead-doors.d.ts +32 -0
  41. package/dist/lead-doors.js +80 -14
  42. package/dist/lead-status.d.ts +3 -0
  43. package/dist/lead-status.js +8 -3
  44. package/dist/lead-tools.d.ts +20 -0
  45. package/dist/lead-tools.js +66 -3
  46. package/dist/lead-voice.js +1 -1
  47. package/dist/lead.js +18 -2
  48. package/dist/local-owner.js +2 -0
  49. package/dist/one-step-chat-schema.d.ts +22 -0
  50. package/dist/one-step-chat-schema.js +37 -0
  51. package/dist/operate.d.ts +27 -1
  52. package/dist/operate.js +206 -32
  53. package/dist/project-delete.js +9 -0
  54. package/dist/project-knowledge.d.ts +7 -7
  55. package/dist/publish.d.ts +2 -0
  56. package/dist/publish.js +1 -1
  57. package/dist/pull-request-flow.d.ts +4 -0
  58. package/dist/pull-request-flow.js +16 -1
  59. package/dist/release-adapters.js +30 -14
  60. package/dist/release-gate.d.ts +3 -3
  61. package/dist/release-gate.js +7 -4
  62. package/dist/release-next.d.ts +3 -3
  63. package/dist/release-next.js +13 -16
  64. package/dist/reply-voice.d.ts +23 -0
  65. package/dist/reply-voice.js +37 -0
  66. package/dist/scope.d.ts +6 -1
  67. package/dist/scope.js +3 -2
  68. package/dist/server/chat.js +4 -4
  69. package/dist/server/chrome.js +2 -2
  70. package/dist/server/home.d.ts +4 -3
  71. package/dist/server/home.js +33 -28
  72. package/dist/server/render-chat.js +11 -6
  73. package/dist/server/render-pages.js +2 -2
  74. package/dist/setup-guide.d.ts +2 -2
  75. package/dist/staged-runtimes.js +2 -1
  76. package/dist/store.d.ts +4 -2
  77. package/dist/store.js +35 -12
  78. package/dist/telegram-lead.js +69 -5
  79. package/dist/telegram-status.js +9 -4
  80. package/dist/telegram.d.ts +10 -0
  81. package/dist/telegram.js +92 -8
  82. package/dist/toolroll-update.js +3 -1
  83. package/dist/tui-contract.d.ts +2 -0
  84. package/dist/tui-source.js +3 -0
  85. package/dist/tui.js +41 -41
  86. package/dist/typed-start.d.ts +51 -0
  87. package/dist/typed-start.js +98 -0
  88. package/dist/wake-hub.d.ts +72 -0
  89. package/dist/wake-hub.js +151 -0
  90. package/package.json +1 -1
@@ -17,6 +17,7 @@ import { taskControlOf } from "./task-control.js";
17
17
  import { taskWorkSummaryOf } from "./work-summary.js";
18
18
  import { assignmentOf, assignmentBrief } from "./assignment.js";
19
19
  import { validateScopeText, validateTaskText } from "./task-text.js";
20
+ import { fastRefusalWords } from "./fast-start.js";
20
21
  import { conversationKnowledge, knowledgeView } from "./project-knowledge.js";
21
22
  import { fixTaskOf, flakyList, flakyShow } from "./flaky.js";
22
23
  import { cancelCommitment, CHECK_RESULTS, conditionWords, latestResultRun, recordCommitment, RUN_OUTCOMES, TASK_STATES } from "./lead-commitments.js";
@@ -393,7 +394,7 @@ export function agentsOver(store, taskId, now) {
393
394
  export const LEAD_CORE_TOOLS = new Set([
394
395
  "find_tools", "get_brief", "recap", "list_repos", "get_project_context", "get_project_activity", "search_project_memory", "get_capabilities",
395
396
  "list_tasks", "get_task", "get_result", "get_diff", "get_check_log", "list_decisions", "get_decision",
396
- "get_actions", "get_action_status", "propose_action", "propose_task", "propose_scope", "propose_steer",
397
+ "get_actions", "get_action_status", "propose_action", "propose_task", "propose_start", "propose_scope", "propose_steer",
397
398
  "propose_answer", "propose_review", "remember", "ask_owner", "commit_to",
398
399
  // The lead's queue verbs: basic, and small enough that deferring them saves almost nothing.
399
400
  "queue", "propose_next", "propose_reserve", "propose_hold", "propose_unhold", "propose_cancel",
@@ -596,7 +597,7 @@ const LEAD_TOOL_HANDLERS = {
596
597
  },
597
598
  get_actions: {
598
599
  description: "List shared actions and required inputs. All channels use the same approvals.",
599
- handle: () => ({ ok: true, body: { actions: Object.entries(CHAT_ACTIONS).filter(([operation]) => !operation.startsWith('flow_') && !operation.startsWith('subagent_')).map(([operation, value]) => ({ operation, label: value.label, secureReview: value.protected, inputs: CHAT_ACTION_FIELDS[operation].filter(field => field !== 'nonce' && field !== 'files') })), notice: 'Passwords and credentials never belong in a tool call or conversation. Secure review links complete protected actions.' } }),
600
+ handle: () => ({ ok: true, body: { actions: Object.entries(CHAT_ACTIONS).filter(([operation]) => !operation.startsWith('flow_') && !operation.startsWith('subagent_') && operation !== 'task_start').map(([operation, value]) => ({ operation, label: value.label, secureReview: value.protected, inputs: CHAT_ACTION_FIELDS[operation].filter(field => field !== 'nonce' && field !== 'files') })), notice: 'Passwords and credentials never belong in a tool call or conversation. Secure review links complete protected actions.' } }),
600
601
  },
601
602
  propose_action: {
602
603
  description: "Read get_actions and relevant skills/evidence first. Save an exact-state proposal only; protected or long terms require full secure review.",
@@ -608,6 +609,9 @@ const LEAD_TOOL_HANDLERS = {
608
609
  return { ok: false, message: 'Use propose_flow for flows.' };
609
610
  if (operation.startsWith('subagent_'))
610
611
  return { ok: false, message: 'Use propose_subagent for subagents.' };
612
+ // A Start card is drafted only by propose_start, whose preview comes from the turn loop, never from arguments.
613
+ if (operation === 'task_start')
614
+ return { ok: false, message: 'Use propose_start for a small change to start now.' };
611
615
  const input = { ...args };
612
616
  delete input['operation'];
613
617
  if (operation.startsWith('skill_') || operation.startsWith('knowledge_') || operation.startsWith('tool_') || operation === 'mode_revoke' || operation === 'memory_decide') {
@@ -1709,7 +1713,7 @@ const LEAD_TOOL_HANDLERS = {
1709
1713
  },
1710
1714
  },
1711
1715
  search_project_memory: {
1712
- description: "Search decisions, instructions, references, lessons and the conversations you may read, across your projects. Read-only; cite the kind and id of what you rely on.",
1716
+ description: "Search decisions, instructions, references, lessons and the conversations you may read, across your projects. Read-only; name what you rely on by what it settled ('you chose Postgres in March'); give its kind and id when asked.",
1713
1717
  handle: (ctx, args) => {
1714
1718
  const repo = args['repo'] === undefined ? null : repoPathOf(ctx.who, args['repo']);
1715
1719
  if (args['repo'] !== undefined && repo === null)
@@ -1925,6 +1929,35 @@ const LEAD_TOOL_HANDLERS = {
1925
1929
  return { ok: true, body: { proposal: id, kind: "task", repo: args["repo"], deliverable: report ? "report" : "branch", planning: report ? "not needed for a scout" : planning, awaiting: "the operator's confirmation" } };
1926
1930
  },
1927
1931
  },
1932
+ propose_start: {
1933
+ description: "The person asked for one small, clear change to be done now. Toolroll decides whether it can start right away; if not, you get an ordinary task card. title is the change in their words, one line.",
1934
+ handle: (ctx, args) => {
1935
+ const checked = startInputOf(ctx, args);
1936
+ if (!checked.ok)
1937
+ return { ok: false, message: checked.message };
1938
+ const { repo, repoId, title } = checked.input;
1939
+ // Eligible: a Start card from the loop's own preview (the request names it back; the card checks it is this
1940
+ // person's, this turn's, on chat). Anything else: today's task card, built directly, and why in the body.
1941
+ const prepared = ctx.startPrepared;
1942
+ let why = fastRefusalWords(prepared === undefined ? "classifier" : prepared.eligible ? "classifier" : prepared.reason);
1943
+ if (prepared !== undefined && prepared.eligible) {
1944
+ try {
1945
+ const action = prepareSharedAction(ctx.store, ctx.who, "task_start", { repo, preview: prepared.preview }, ctx.evidenceRoot, ctx.now, ctx.turn);
1946
+ const id = ctx.draft("action", { ...action });
1947
+ if (id === null)
1948
+ return tooMany();
1949
+ return { ok: true, body: { proposal: id, kind: "action", label: action.title, startsNow: true, awaiting: "the person's own yes on Start work", executed: false } };
1950
+ }
1951
+ catch (error) {
1952
+ why = error instanceof Error ? error.message : why;
1953
+ }
1954
+ }
1955
+ const id = ctx.draft("task", { repo, repoId, title, goal: title, not: null, touches: [], acceptance: [{ id: "c1", statement: title, evidence: ["manual-review"] }], planning: "skip", report: false });
1956
+ if (id === null)
1957
+ return tooMany();
1958
+ return { ok: true, body: { proposal: id, kind: "task", repo: repoId, startsNow: false, why, awaiting: "the operator's confirmation" } };
1959
+ },
1960
+ },
1928
1961
  propose_sweep: {
1929
1962
  description: "Several independent items under ONE shared rule set (a bug sweep, a batch of small fixes): 2-20 items, each becomes its own task in its own worktree, started with one approval, at most max_parallel (default 3) at once, each reporting a verdict into one table. brief holds the shared rules every item gets (e.g. reproduce first; 'not reproducible' is a valid result; prove the fix); each item is only its own report. Not for one task, and not for steps that depend on each other.",
1930
1963
  handle: (ctx, args) => {
@@ -2515,3 +2548,33 @@ export function executeLeadTool(ctx, name, args, view) {
2515
2548
  export function toolResultBytes(text) {
2516
2549
  return Buffer.byteLength(JSON.stringify(text), "utf8");
2517
2550
  }
2551
+ /** What propose_start may start with, checked before Toolroll asks the classifier (docs/plans/one-step-chat.md C3): the
2552
+ * call's contract, the same memory and capability checks every proposal makes, never a team chat, a project the person
2553
+ * can reach now, and the same text and secret checks as propose_task. The conversation loop runs this, and only then
2554
+ * prepares a preview; the handler runs it again. Invalid input never reaches the classifier. */
2555
+ export function startInputOf(ctx, args) {
2556
+ const tool = LEAD_TOOLS.find(one => one.name === "propose_start");
2557
+ if (tool === undefined)
2558
+ return { ok: false, message: "propose_start isn't available." };
2559
+ const read = tool.read(ctx, args);
2560
+ if (!read.ok)
2561
+ return { ok: false, message: read.issues.map(one => one.line).join("\n") };
2562
+ const unchecked = memoryUnsearched(ctx, "propose_start", read.value) ?? capabilitiesUnread(ctx, "propose_start", read.value);
2563
+ if (unchecked !== null && !unchecked.ok)
2564
+ return { ok: false, message: unchecked.message };
2565
+ // A start is the person's own yes in their own chat with you: a shared team chat proposes an ordinary task.
2566
+ if (teamRoomOf(ctx.store, ctx.thread) !== null)
2567
+ return { ok: false, message: "Starting work in one step is for the person's own chat. Use propose_task here." };
2568
+ const repo = repoPathOf(ctx.who, read.value["repo"]);
2569
+ if (repo === null || !ctx.store.accountCanAccess(ctx.who.name, repo))
2570
+ return { ok: false, message: "repo must be one of the ids from list_repos" };
2571
+ const title = read.value["title"];
2572
+ if (typeof title !== "string")
2573
+ return { ok: false, message: "title is the change, one line" };
2574
+ const badText = validateTaskText({ title, goal: title, outOfScope: null });
2575
+ if (badText !== null)
2576
+ return { ok: false, message: badText.message };
2577
+ if (scanForSecrets(title).length > 0)
2578
+ return { ok: false, message: "Task text cannot contain credentials." };
2579
+ return { ok: true, input: { repo, repoId: String(read.value["repo"]), title } };
2580
+ }
@@ -11,7 +11,7 @@ import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
11
11
  import { BUILT_IN, refArchived } from "./store.js";
12
12
  import { leadNameOf } from "./lead-identity.js";
13
13
  import { leadLapsed } from "./task-status.js";
14
- import { phoneText as plain } from "./telegram-status.js";
14
+ import { publicChatText as plain } from "./chat-display.js";
15
15
  export const LEAD_SAY_KIND = "lead-say";
16
16
  /** Repaints the owner's task card when the lead takes a task on: it never makes a new message in quiet chat. */
17
17
  export const LEAD_ON_IT_KIND = "lead-on-it";
package/dist/lead.js CHANGED
@@ -9,7 +9,8 @@ import { LEAD_MAX_CALLS_PER_STEP, LEAD_MAX_STEPS, LEAD_TOOL_RESULT_CAP_BYTES, TU
9
9
  import { redactSecretLines, scanForSecrets } from "./evidence.js";
10
10
  import { redactSecretAssignments } from "./builder.js";
11
11
  import { LEAD_CONTRACT } from "./lead-contract.js";
12
- import { LEAD_CORE_TOOLS, LEAD_MAX_PROPOSALS_PER_TURN, LEAD_TOOL_SCHEMAS, executeLeadTool, isLeadTool, leadViewContextFor, projectLabelForLead, redactForLead, toolResultBytes } from "./lead-tools.js";
12
+ import { LEAD_CORE_TOOLS, LEAD_MAX_PROPOSALS_PER_TURN, LEAD_TOOL_SCHEMAS, executeLeadTool, isLeadTool, leadViewContextFor, projectLabelForLead, redactForLead, startInputOf, toolResultBytes } from "./lead-tools.js";
13
+ import { prepareChatStart } from "./fast-start.js";
13
14
  import { composeSubscriptionLeadPrompt, performSubscriptionLeadRequest } from "./subscription-chat.js";
14
15
  import { LEAD_REPLY_LIMITS, leadContext } from './lead-context.js';
15
16
  import { shortenAsk } from './text-limits.js';
@@ -616,9 +617,24 @@ export async function runLeadTurn(input) {
616
617
  progress({ kind: "tool", turn: turnId, step: steps, id: progressId, label: leadToolLabel(call.name) });
617
618
  // A registered tool this request did not advertise is refused before it is read or run (the only production
618
619
  // caller of executeLeadTool): the model learns the road (find_tools, then the next step) and the reply goes on.
620
+ const toolContext = () => ({ store, who, now: clock(), draft, selectEvidence, ...(input.replyImage === undefined ? {} : { replyImage: input.replyImage }), step: steps, readDecisions, readResults, searchedMemory, checkedCapabilities, ask, findTools, ...(input.integrations === undefined ? {} : { integrations: input.integrations }), thread: thread.id, turn: turnId, ...(promiseChannelOfTurn(input.channel) === undefined ? {} : { channel: promiseChannelOfTurn(input.channel) }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) });
621
+ // propose_start (docs/plans/one-step-chat.md C3), after the advertisement gate: its input is validated first (so
622
+ // invalid input never reaches the classifier), then Toolroll prepares this call's own preview in this turn (up to
623
+ // the classifier's budget), then the person's standing and the thread are re-proved after that wait, and only then
624
+ // does the tool run, with the preview in this call's own context. The model's arguments never name it.
625
+ let startPrepared;
626
+ if (call.name === "propose_start" && advertisedNames.has(call.name)) {
627
+ const checked = startInputOf(toolContext(), call.args);
628
+ if (checked.ok) {
629
+ startPrepared = await prepareChatStart(store, { actor: who.name, repo: checked.input.repo, title: checked.input.title, turn: turnId }, clock());
630
+ const changed = guard(await revalidated());
631
+ if (changed !== null)
632
+ return changed;
633
+ }
634
+ }
619
635
  const outcome = !advertisedNames.has(call.name)
620
636
  ? { ok: false, message: `${call.name} isn't available in this step. Call find_tools with what you want to do; tools it finds can be called from your next step.` }
621
- : executeLeadTool({ store, who, now: clock(), draft, selectEvidence, ...(input.replyImage === undefined ? {} : { replyImage: input.replyImage }), step: steps, readDecisions, readResults, searchedMemory, checkedCapabilities, ask, findTools, ...(input.integrations === undefined ? {} : { integrations: input.integrations }), thread: thread.id, turn: turnId, ...(promiseChannelOfTurn(input.channel) === undefined ? {} : { channel: promiseChannelOfTurn(input.channel) }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) }, call.name, call.args, view);
637
+ : executeLeadTool({ ...toolContext(), ...(startPrepared === undefined ? {} : { startPrepared }) }, call.name, call.args, view);
622
638
  let displayOutcome = { state: "succeeded" };
623
639
  if (!outcome.ok) {
624
640
  const reason = leadToolFailureReason(call.name, outcome.message);
@@ -181,6 +181,8 @@ export const CHAT_ACTION_LOCAL_ACTS = Object.freeze({
181
181
  result_accept: "complete-task",
182
182
  task_cancel: "stop-run",
183
183
  task_resume: "resume-run",
184
+ // A Start card: the fast start's own act (approve() allows it only with a fast marker).
185
+ task_start: "build-now",
184
186
  });
185
187
  /** Every task card operation (chat-task-actions.ts), decided for the local owner. */
186
188
  export const CHAT_TASK_ACTION_LOCAL_ACTS = Object.freeze({
@@ -0,0 +1,22 @@
1
+ /**
2
+ * v159: one-step start from chat (docs/plans/one-step-chat.md). Adds only: a saved row reads as it did. Kept beside
3
+ * nothing else so the update road can read the lists without the store, and so the version is easy to renumber.
4
+ *
5
+ * - `fast_preview.turn`: the lead turn a chat start's preview was prepared in (fast-start.ts prepareChatStart). Saved
6
+ * rows read NULL; a chat preview without a turn is unusable, and the form's previews never have one.
7
+ * - `chat_typed_start` (C5): a typed yes to a Start card, recorded with the inbound message in the transaction that saves
8
+ * it (typed-start.ts): which card it answered (the proposal and the chat message that carried it), the card's action
9
+ * stamp then, how it was tied to the card (`reply` to it, or the `next` message after it), and once settled, the
10
+ * outcome and the task it started. A new table: nothing saved changes. `proposal` is a plain number, not a reference:
11
+ * the record has to outlive its card. Ending the chat deletes the card's proposal, and a yes already recorded for it
12
+ * must still be answered by Toolroll ("didn't start anything") and never handed to the lead as a new message. Proposal
13
+ * ids are never reused (AUTOINCREMENT), so a kept number can't come to name another card.
14
+ */
15
+ export declare const V159_SCHEMA = "\nCREATE TABLE IF NOT EXISTS chat_typed_start (\n provider TEXT NOT NULL CHECK (provider IN ('telegram')),\n event TEXT NOT NULL,\n proposal INTEGER NOT NULL,\n card_message TEXT NOT NULL,\n stamp TEXT NOT NULL,\n how TEXT NOT NULL CHECK (how IN ('reply', 'next')),\n created TEXT NOT NULL,\n outcome TEXT CHECK (outcome IS NULL OR outcome IN ('started', 'refused', 'not-counted')),\n task_id TEXT,\n finished TEXT,\n PRIMARY KEY (provider, event),\n FOREIGN KEY (provider, event) REFERENCES chat_event(provider, id)\n);\nCREATE INDEX IF NOT EXISTS chat_typed_start_proposal ON chat_typed_start (proposal);\n";
16
+ export declare const V159_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
17
+ definition: string;
18
+ saved: string;
19
+ }>>>>;
20
+ export declare const V159_TABLES: readonly ["chat_typed_start"];
21
+ /** The v159 migration's steps, in order (a test seam may stop after each). */
22
+ export declare const V159_STEPS: readonly ["one-step-chat-tables", "one-step-chat-columns"];
@@ -0,0 +1,37 @@
1
+ /**
2
+ * v159: one-step start from chat (docs/plans/one-step-chat.md). Adds only: a saved row reads as it did. Kept beside
3
+ * nothing else so the update road can read the lists without the store, and so the version is easy to renumber.
4
+ *
5
+ * - `fast_preview.turn`: the lead turn a chat start's preview was prepared in (fast-start.ts prepareChatStart). Saved
6
+ * rows read NULL; a chat preview without a turn is unusable, and the form's previews never have one.
7
+ * - `chat_typed_start` (C5): a typed yes to a Start card, recorded with the inbound message in the transaction that saves
8
+ * it (typed-start.ts): which card it answered (the proposal and the chat message that carried it), the card's action
9
+ * stamp then, how it was tied to the card (`reply` to it, or the `next` message after it), and once settled, the
10
+ * outcome and the task it started. A new table: nothing saved changes. `proposal` is a plain number, not a reference:
11
+ * the record has to outlive its card. Ending the chat deletes the card's proposal, and a yes already recorded for it
12
+ * must still be answered by Toolroll ("didn't start anything") and never handed to the lead as a new message. Proposal
13
+ * ids are never reused (AUTOINCREMENT), so a kept number can't come to name another card.
14
+ */
15
+ export const V159_SCHEMA = `
16
+ CREATE TABLE IF NOT EXISTS chat_typed_start (
17
+ provider TEXT NOT NULL CHECK (provider IN ('telegram')),
18
+ event TEXT NOT NULL,
19
+ proposal INTEGER NOT NULL,
20
+ card_message TEXT NOT NULL,
21
+ stamp TEXT NOT NULL,
22
+ how TEXT NOT NULL CHECK (how IN ('reply', 'next')),
23
+ created TEXT NOT NULL,
24
+ outcome TEXT CHECK (outcome IS NULL OR outcome IN ('started', 'refused', 'not-counted')),
25
+ task_id TEXT,
26
+ finished TEXT,
27
+ PRIMARY KEY (provider, event),
28
+ FOREIGN KEY (provider, event) REFERENCES chat_event(provider, id)
29
+ );
30
+ CREATE INDEX IF NOT EXISTS chat_typed_start_proposal ON chat_typed_start (proposal);
31
+ `;
32
+ export const V159_ADDED_COLUMNS = Object.freeze({
33
+ fast_preview: { turn: { definition: "INTEGER", saved: "NULL" } },
34
+ });
35
+ export const V159_TABLES = Object.freeze(["chat_typed_start"]);
36
+ /** The v159 migration's steps, in order (a test seam may stop after each). */
37
+ export const V159_STEPS = Object.freeze(["one-step-chat-tables", "one-step-chat-columns"]);
package/dist/operate.d.ts CHANGED
@@ -5,7 +5,7 @@ import { type TriggerIo } from "./flow-triggers.js";
5
5
  import { type GhRead } from "./nightly-github.js";
6
6
  import type { StepIo } from "./flow-steps.js";
7
7
  import type { FetchLike } from "./flow-share.js";
8
- import { type Store } from "./store.js";
8
+ import { type Store, type Publication } from "./store.js";
9
9
  import { type LaptopSeams } from "./laptop-background.js";
10
10
  import { type DispatchAdapter } from "./sync.js";
11
11
  import { type ParentToRole, type RolePort, type UpRoleStart, type UpStartAuthority } from "./up-processes.js";
@@ -373,3 +373,29 @@ export declare function upLaptopPortCandidates(store: Store, fromSeam?: readonly
373
373
  /** The watch loop's background learning pass (docs/plans/learning-loops.md): advisory reads, each project at most every
374
374
  * 30 minutes through the plane's own gh. A failure here never reaches the loop: it is tried on the next pass. */
375
375
  export declare function refreshLearning(store: Store, now: () => Date, reads?: GhRead): Promise<void>;
376
+ /** A project loop's share in its process's service beats. */
377
+ export type ServiceBeatMember = {
378
+ context: Context;
379
+ progress: (line: string) => void;
380
+ /** This loop's reconciliation stands and it is not stopping: the fast CI pass runs only while every member's does. */
381
+ ciReady: () => boolean;
382
+ };
383
+ /** The delivery beat: web push and outgoing webhooks, off the build path. */
384
+ export declare const DELIVERY_EVERY_MS = 5000;
385
+ /** The fast CI beat: only while an opened pull request waits on its checks or its merge. */
386
+ export declare const FAST_CI_EVERY_MS = 30000;
387
+ /** The most pull requests one fast CI pass reads. */
388
+ export declare const FAST_CI_LIMIT = 10;
389
+ /**
390
+ * The opened pull requests the fast CI pass reads, oldest read or tried (`attempted`) first, at most `limit`: checks not settled (never read,
391
+ * running, or "none" shortly after an update), or green with its merge still waiting (a merge intent in flight, or a
392
+ * followed pull request not yet marked ready on its accepted commit). Failing and settled ones stay on the 5 minute pass.
393
+ */
394
+ export declare function fastCiWaiting(store: Store, now: Date, limit?: number, attempted?: ReadonlyMap<number, string>): Publication[];
395
+ /**
396
+ * Join this process's service beats: one delivery beat (web push and outgoing webhooks every 5 s, and at once when
397
+ * the wake moves with new notifications) and one fast CI beat (every 30 s, the existing `observeChecks` reader, only
398
+ * for pull requests that wait). One set per store, however many project loops join; the last to leave stops them.
399
+ * Opening a pull request stays on the tick path, and the 5 minute pass alone may set reconciliation ready.
400
+ */
401
+ export declare function joinServiceBeats(member: ServiceBeatMember): () => Promise<void>;
package/dist/operate.js CHANGED
@@ -281,6 +281,7 @@ import { noticeGitIncidents } from "./git-incident.js";
281
281
  import { containmentNotice, containmentStatus, currentContainment, describeContainment, resolveContainment } from "./containment.js";
282
282
  import { readPulls } from "./pulls.js";
283
283
  import { startMaintenance } from "./maintenance.js";
284
+ import { WakePoker, wakeHubFor } from "./wake-hub.js";
284
285
  import { livePin, modelWatchPass } from "./model-catalog.js";
285
286
  import { runModelsCommand } from "./models-cli.js";
286
287
  import { ALL_PROJECTS, chatApprovalSettings, chatApprovalWords, setChatApproval } from "./chat-approval.js";
@@ -6427,8 +6428,22 @@ async function runWatchLoop(args) {
6427
6428
  // an authority this process no longer holds.
6428
6429
  let leaseLost = false;
6429
6430
  const followController = new AbortController();
6431
+ // The process's one wake poller (wake-hub.ts) replaces every per-loop and per-lane 50 ms read of the wake counter.
6432
+ // It only says "look": every claim still re-proves its fences.
6433
+ const hub = wakeHubFor(store);
6434
+ const wakes = new WakePoker();
6430
6435
  args.onFollowController?.(followController);
6431
- const stopping = () => args.isStopping() || leaseLost;
6436
+ // The loop or a build lane failed (the store could not be read): everything stops, cleans up, and then the first
6437
+ // failure is thrown. Without this a failed lane skipped the cleanup, and a failed loop left its lanes running.
6438
+ let failed = false;
6439
+ let failure;
6440
+ const fail = (error) => {
6441
+ if (!failed)
6442
+ failure = error;
6443
+ failed = true;
6444
+ wakes.poke();
6445
+ };
6446
+ const stopping = () => args.isStopping() || leaseLost || failed;
6432
6447
  const heartbeat = setInterval(() => {
6433
6448
  let renewed = false;
6434
6449
  let problem = "the lease or credential was taken";
@@ -6441,6 +6456,8 @@ async function runWatchLoop(args) {
6441
6456
  if (!renewed && !leaseLost) {
6442
6457
  leaseLost = true;
6443
6458
  followController.abort();
6459
+ // A lost lease ends every doze of this loop and its lanes now, not at the next beat (live-wakeups.md).
6460
+ wakes.poke();
6444
6461
  progress(`watch: ${runner} on ${repo}: ${problem} — stopping without admitting more work`);
6445
6462
  }
6446
6463
  }, WATCH_HEARTBEAT_MS);
@@ -6495,12 +6512,9 @@ async function runWatchLoop(args) {
6495
6512
  const deadline = runFor === null ? null : startedAt + runFor;
6496
6513
  let lastTick = 0;
6497
6514
  let lastBridge = 0;
6498
- let lastPush = 0;
6499
- let lastWebhook = 0;
6500
6515
  let ticks = 0;
6501
6516
  let built = 0;
6502
6517
  let brokeCount = 0;
6503
- const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
6504
6518
  // Reconciliation has its own cadence and sink. Sharing tick's sink would
6505
6519
  // interleave envelopes while a build awaits its provider or project check.
6506
6520
  const sweepOwner = {};
@@ -6525,8 +6539,12 @@ async function runWatchLoop(args) {
6525
6539
  throw new Error(maintenanceLines.join(" "));
6526
6540
  if (reconciliationFailure !== null)
6527
6541
  progress("watch: reconciliation recovered — new work may resume");
6542
+ const recovered = !reconciliationReady;
6528
6543
  reconciliationFailure = null;
6529
6544
  reconciliationReady = true;
6545
+ // Admission resumes now: a loop dozing while paused looks again at once.
6546
+ if (recovered)
6547
+ wakes.poke();
6530
6548
  if (!stopping() && store.openedPublications().length > 0) {
6531
6549
  try {
6532
6550
  const checks = await observeChecks(store, {
@@ -6654,6 +6672,9 @@ async function runWatchLoop(args) {
6654
6672
  run: async () => { if (await modelWatchPass(store, context.clock()))
6655
6673
  progress("watch: checked models and CLI versions"); },
6656
6674
  });
6675
+ // The process's delivery and fast CI beats (live-wakeups.md): one set however many project loops run.
6676
+ // The fast CI pass runs only while this loop would admit work: not stopping, reconciled, and no update drain.
6677
+ const leaveServiceBeats = joinServiceBeats({ context, progress, ciReady: () => !admissionStopped() });
6657
6678
  // Parallel builds (project-concurrency.ts): this loop is the project's
6658
6679
  // first build lane and keeps every housekeeping duty; extra lanes, up to
6659
6680
  // the project's own number (never past the worker's capacity), only
@@ -6729,16 +6750,16 @@ async function runWatchLoop(args) {
6729
6750
  const seqIdle = store.wakeSeq();
6730
6751
  const dozeUntil = Math.min(Date.now() + tickEveryMs, deadline ?? Number.MAX_SAFE_INTEGER);
6731
6752
  const theirTurnEnded = () => yieldingTo !== null && yieldingSince !== null && passes.lastPass(yieldingTo) > yieldingSince;
6732
- while (!stopping() && Date.now() < dozeUntil && store.wakeSeq() === seqIdle && !theirTurnEnded()) {
6733
- await sleep(paused() ? 250 : 50);
6734
- }
6753
+ // Doze on the shared poller: a wake, the cadence, a stop, a lost lease or the fair-share turn ending.
6754
+ await hub.changed({ baseline: seqIdle, deadline: dozeUntil, until: () => stopping() || theirTurnEnded(), poker: wakes });
6735
6755
  }
6736
6756
  };
6737
6757
  const ensureLanes = () => {
6738
6758
  const facts = slotFacts(null);
6739
6759
  const wanted = effectiveConcurrency(facts.limitOf(repo), facts.capacity);
6760
+ // A lane never rejects: its failure is kept, every doze is woken, and the loop's own cleanup runs before it is thrown.
6740
6761
  while (lanes.length + 1 < wanted && !stopping())
6741
- lanes.push(buildLane(lanes.length + 1));
6762
+ lanes.push(buildLane(lanes.length + 1).catch(fail));
6742
6763
  };
6743
6764
  let firstLaneYieldingSince = null;
6744
6765
  let firstLaneYieldingTo = null;
@@ -6747,7 +6768,9 @@ async function runWatchLoop(args) {
6747
6768
  await maintenance.runNow();
6748
6769
  while (!stopping() && (deadline === null || Date.now() < deadline)) {
6749
6770
  if (paused()) {
6750
- await sleep(250);
6771
+ // Paused: look again when the wake moves (pause and resume bump it), when reconciliation recovers (a poke),
6772
+ // or after a second at most, whichever comes first.
6773
+ await hub.changed({ baseline: store.wakeSeq(), deadline: Date.now() + 1_000, until: stopping, poker: wakes });
6751
6774
  continue;
6752
6775
  }
6753
6776
  ensureLanes();
@@ -6817,25 +6840,8 @@ async function runWatchLoop(args) {
6817
6840
  (published.failed > 0 ? `, gave up on ${published.failed}` : ""));
6818
6841
  }
6819
6842
  }
6820
- // Push rides its OWN cadence, whether or not Telegram holds the wire
6821
- // (arc 3 finding 8): the pair ledger's claims fence concurrent loops.
6822
- if (now - lastPush >= 45_000) {
6823
- lastPush = now;
6824
- try {
6825
- await pushPass(context.store, { configDir: dirname(context.databaseFile), clock: () => new Date() });
6826
- }
6827
- catch {
6828
- // Push is additive; a broken pass never stops the watch.
6829
- }
6830
- }
6831
- if (now - lastWebhook >= bridgeEveryMs) {
6832
- lastWebhook = now;
6833
- const dir = dirname(context.databaseFile);
6834
- const primary = effectivePrimary(process.env, dir, loadBotToken(process.env, context.telegramTokenFile) !== null);
6835
- const targets = activeWebhookTargets(process.env, dir, primary.channel);
6836
- if (targets.length > 0)
6837
- await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock: context.clock });
6838
- }
6843
+ // Web push and outgoing webhooks ride the service's own delivery beat (joinServiceBeats, live-wakeups.md):
6844
+ // every 5 s, and at once when the wake moves with new rows, never behind a build on this path.
6839
6845
  // The embedded follower owns the wire while it lives; the timer-driven
6840
6846
  // pass is the fallback shape for a watch started before a token existed.
6841
6847
  if (follower === null && now - lastBridge >= bridgeEveryMs) {
@@ -6864,7 +6870,7 @@ async function runWatchLoop(args) {
6864
6870
  // due, a signal lands, or the trial window (--for) ends. Reading one
6865
6871
  // integer from SQLite twice a second is the entire idle cost — no
6866
6872
  // token has anywhere to be spent from here.
6867
- const idleUntil = Math.min(lastTick + tickEveryMs, lastBridge + bridgeEveryMs, lastWebhook + bridgeEveryMs, deadline ?? Number.MAX_SAFE_INTEGER) - Date.now();
6873
+ const idleUntil = Math.min(lastTick + tickEveryMs, lastBridge + bridgeEveryMs, deadline ?? Number.MAX_SAFE_INTEGER) - Date.now();
6868
6874
  // Doze for the WHOLE idle window, waking early only for a signal or a
6869
6875
  // sequence move — not for one 500ms step and then another pass
6870
6876
  // (2026-09-05: that read as two ticks a second, all night, with
@@ -6872,11 +6878,13 @@ async function runWatchLoop(args) {
6872
6878
  const seqIdle = store.wakeSeq();
6873
6879
  const dozeUntil = Date.now() + Math.max(idleUntil, 0);
6874
6880
  const theirTurnEnded = () => firstLaneYieldingTo !== null && firstLaneYieldingSince !== null && passes.lastPass(firstLaneYieldingTo) > firstLaneYieldingSince;
6875
- while (!stopping() && Date.now() < dozeUntil && store.wakeSeq() === seqIdle && !theirTurnEnded()) {
6876
- await sleep(50);
6877
- }
6881
+ await hub.changed({ baseline: seqIdle, deadline: dozeUntil, until: () => stopping() || theirTurnEnded(), poker: wakes });
6878
6882
  }
6879
6883
  }
6884
+ catch (error) {
6885
+ // Kept, not thrown yet: the lanes must see the stop, and the cleanup below must run first.
6886
+ fail(error);
6887
+ }
6880
6888
  finally {
6881
6889
  // Every build lane finishes or hands its attempt back before the
6882
6890
  // loop's lease and episode end.
@@ -6887,6 +6895,7 @@ async function runWatchLoop(args) {
6887
6895
  await flowMoves.stop();
6888
6896
  await flowSteps.stop();
6889
6897
  await modelWatch.stop();
6898
+ await leaveServiceBeats();
6890
6899
  clearInterval(heartbeat);
6891
6900
  followController.abort();
6892
6901
  if (follower !== null)
@@ -6897,6 +6906,8 @@ async function runWatchLoop(args) {
6897
6906
  store.endWatchEpisode(incarnation, { ticks, built, broke: brokeCount }, new Date());
6898
6907
  store.releaseWatchLease(runner, repo, incarnation, new Date());
6899
6908
  }
6909
+ if (failed)
6910
+ throw failure;
6900
6911
  if (leaseLost) {
6901
6912
  return { ok: false, reason: "lease-lost", detail: `the watch lease for ${runner} on ${repo} stopped renewing — another process may have taken this worker over`, ticks, built, broke: brokeCount };
6902
6913
  }
@@ -6966,6 +6977,7 @@ async function watchCommand(flags, context) {
6966
6977
  }
6967
6978
  stopping = true;
6968
6979
  followAbort?.abort();
6980
+ wakeHubFor(context.store).poke();
6969
6981
  graceTimer = setTimeout(hardStop, stopGraceMs);
6970
6982
  };
6971
6983
  process.on("SIGINT", stop);
@@ -12628,6 +12640,8 @@ async function runUpWorkerRole(context, config, port, build) {
12628
12640
  resolveStopped();
12629
12641
  for (const controller of followControllers)
12630
12642
  controller.abort();
12643
+ // Every project loop's doze ends now (wake-hub.ts), not at its next beat.
12644
+ wakeHubFor(store).poke();
12631
12645
  const building = store.liveClaimCount(runnerName, clock());
12632
12646
  if (building > 0) {
12633
12647
  progress(`Stopping: ${building} build(s) running. Waiting up to ${Math.round(config.stopGraceMs / 1000)}s for them to finish; any still running then is saved and resumes when the builder starts again.`);
@@ -14125,3 +14139,163 @@ async function quickAnswersConfig(action, repoGiven, flags, context) {
14125
14139
  const shown = said();
14126
14140
  return succeed(write, json, command, shown.payload, shown.lines);
14127
14141
  }
14142
+ /** The delivery beat: web push and outgoing webhooks, off the build path. */
14143
+ export const DELIVERY_EVERY_MS = 5_000;
14144
+ /** The fast CI beat: only while an opened pull request waits on its checks or its merge. */
14145
+ export const FAST_CI_EVERY_MS = 30_000;
14146
+ /** The most pull requests one fast CI pass reads. */
14147
+ export const FAST_CI_LIMIT = 10;
14148
+ /** A pull request whose checks read as "none" stays on the fast pass this long after it was last updated: CI that
14149
+ * has not registered yet, not a project without CI, which the 5 minute pass keeps reading. */
14150
+ const FAST_CI_NONE_WINDOW_MS = 30 * 60_000;
14151
+ const serviceBeats = new WeakMap();
14152
+ /** The newest notification id: the delivery beat's high-water mark. */
14153
+ function newestNotificationId(store) {
14154
+ return Number(store.handle.prepare("SELECT COALESCE(MAX(id), 0) AS id FROM notification").get()?.["id"] ?? 0);
14155
+ }
14156
+ /**
14157
+ * The opened pull requests the fast CI pass reads, oldest read or tried (`attempted`) first, at most `limit`: checks not settled (never read,
14158
+ * running, or "none" shortly after an update), or green with its merge still waiting (a merge intent in flight, or a
14159
+ * followed pull request not yet marked ready on its accepted commit). Failing and settled ones stay on the 5 minute pass.
14160
+ */
14161
+ export function fastCiWaiting(store, now, limit = FAST_CI_LIMIT, attempted = new Map()) {
14162
+ const waiting = store.openedPublications().filter(publication => {
14163
+ if (publication.prNumber === null)
14164
+ return false;
14165
+ const state = publication.lastCheckState;
14166
+ if (state === null || state === "running")
14167
+ return true;
14168
+ if (state === "none")
14169
+ return now.getTime() - Date.parse(publication.lastCheckAt ?? publication.updatedAt) < FAST_CI_NONE_WINDOW_MS
14170
+ && now.getTime() - Date.parse(publication.updatedAt) < FAST_CI_NONE_WINDOW_MS;
14171
+ if (state !== "passing")
14172
+ return false;
14173
+ const intent = store.mergeIntentFor(publication.id);
14174
+ if (intent !== null && ["pending", "claimed", "waiting-human", "firing"].includes(intent.state))
14175
+ return true;
14176
+ const follow = store.handle.prepare("SELECT flow_card, ready_head, merge_commit FROM pull_request_follow WHERE publication = ?").get(publication.id);
14177
+ return follow !== undefined && follow["flow_card"] == null && follow["merge_commit"] == null && follow["ready_head"] !== publication.headSha;
14178
+ });
14179
+ // Oldest attempt first: a pull request GitHub cannot read still takes its turn, then goes to the back of the line.
14180
+ const last = (one) => {
14181
+ const read = one.lastCheckAt ?? "", tried = attempted.get(one.id) ?? "";
14182
+ return tried > read ? tried : read;
14183
+ };
14184
+ return waiting
14185
+ .sort((a, b) => last(a).localeCompare(last(b)) || a.id - b.id)
14186
+ .slice(0, limit);
14187
+ }
14188
+ /**
14189
+ * Join this process's service beats: one delivery beat (web push and outgoing webhooks every 5 s, and at once when
14190
+ * the wake moves with new notifications) and one fast CI beat (every 30 s, the existing `observeChecks` reader, only
14191
+ * for pull requests that wait). One set per store, however many project loops join; the last to leave stops them.
14192
+ * Opening a pull request stays on the tick path, and the 5 minute pass alone may set reconciliation ready.
14193
+ */
14194
+ export function joinServiceBeats(member) {
14195
+ const store = member.context.store;
14196
+ let beats = serviceBeats.get(store);
14197
+ if (beats === undefined)
14198
+ beats = startServiceBeats(store);
14199
+ beats.members.add(member);
14200
+ const joined = beats;
14201
+ return async () => {
14202
+ if (!joined.members.delete(member) || joined.members.size > 0)
14203
+ return;
14204
+ joined.stopped = true;
14205
+ if (serviceBeats.get(store) === joined)
14206
+ serviceBeats.delete(store);
14207
+ joined.poker.poke();
14208
+ await joined.delivery.stop();
14209
+ await joined.fastCi.stop();
14210
+ await joined.wakeLoop;
14211
+ };
14212
+ }
14213
+ function startServiceBeats(store) {
14214
+ const anyMember = () => beats.members.values().next().value;
14215
+ let deliveredThrough = newestNotificationId(store);
14216
+ let deliveryProblem = null;
14217
+ // When the fast CI pass last tried each pull request, read or not.
14218
+ const attempted = new Map();
14219
+ const delivery = startMaintenance({
14220
+ intervalMs: DELIVERY_EVERY_MS,
14221
+ shouldStop: () => beats.stopped,
14222
+ onError: error => {
14223
+ const detail = describe(error);
14224
+ if (detail !== deliveryProblem)
14225
+ anyMember()?.progress(`watch: outgoing webhooks failed — ${detail}; they try again in a few seconds`);
14226
+ deliveryProblem = detail;
14227
+ },
14228
+ run: async () => {
14229
+ const member = anyMember();
14230
+ if (member === undefined)
14231
+ return;
14232
+ const { context } = member;
14233
+ const dir = dirname(context.databaseFile);
14234
+ deliveredThrough = newestNotificationId(store);
14235
+ // Push is additive; a broken pass never stops the watch. The pair ledger's claims fence any other sender.
14236
+ try {
14237
+ await pushPass(store, { configDir: dir, clock: () => new Date() });
14238
+ }
14239
+ catch { /* tried again next beat */ }
14240
+ const primary = effectivePrimary(process.env, dir, loadBotToken(process.env, context.telegramTokenFile) !== null);
14241
+ const targets = activeWebhookTargets(process.env, dir, primary.channel);
14242
+ if (targets.length > 0)
14243
+ await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock: context.clock });
14244
+ deliveryProblem = null;
14245
+ },
14246
+ });
14247
+ const fastCi = startMaintenance({
14248
+ intervalMs: FAST_CI_EVERY_MS,
14249
+ shouldStop: () => beats.stopped,
14250
+ // A failed fast pass is skipped: it records nothing and says nothing. The 5 minute pass still reads every PR.
14251
+ onError: () => { },
14252
+ run: async () => {
14253
+ // Every project loop must still stand, read again before each revision, ready mark and merge: a stop, a lost
14254
+ // lease, a failed reconciliation or an update drain that arrives while GitHub answers ends the pass there.
14255
+ const standing = () => !beats.stopped && beats.members.size > 0 && [...beats.members].every(one => one.ciReady());
14256
+ if (!standing())
14257
+ return;
14258
+ const { context, progress } = beats.members.values().next().value;
14259
+ const now = context.clock();
14260
+ const waiting = fastCiWaiting(store, now, FAST_CI_LIMIT, attempted);
14261
+ if (waiting.length === 0)
14262
+ return;
14263
+ // A pull request GitHub would not read keeps its place in line only until the others had their turn.
14264
+ if (attempted.size > 500)
14265
+ attempted.clear();
14266
+ for (const one of waiting)
14267
+ attempted.set(one.id, now.toISOString());
14268
+ const exec = context.publishExec === undefined ? {} : { exec: context.publishExec };
14269
+ const checks = await observeChecks(store, { ...exec, publications: waiting });
14270
+ if (!standing())
14271
+ return;
14272
+ if (checks.failing > 0)
14273
+ progress(`watch: CI is red on ${checks.failing} published PR(s) — the outbox has it`);
14274
+ const followed = await followPullRequests(store, checks.seen, { evidenceRoot: context.evidenceRoot, ...exec, proceed: standing });
14275
+ if (followed.revisions > 0)
14276
+ progress(`watch: filed ${followed.revisions} CI revision(s)`);
14277
+ if (followed.merged > 0)
14278
+ progress(`watch: merged ${followed.merged} pull request(s) whose checks passed`);
14279
+ },
14280
+ });
14281
+ const poker = new WakePoker();
14282
+ const hub = wakeHubFor(store);
14283
+ const beats = { members: new Set(), stopped: false, delivery, fastCi, poker, wakeLoop: Promise.resolve() };
14284
+ // A wake with new notifications sends them now, not at the next beat.
14285
+ beats.wakeLoop = (async () => {
14286
+ while (!beats.stopped) {
14287
+ try {
14288
+ const why = await hub.changed({ baseline: store.wakeSeq(), deadline: Number.MAX_SAFE_INTEGER, until: () => beats.stopped, poker });
14289
+ if (why === "wake" && !beats.stopped && newestNotificationId(store) > deliveredThrough)
14290
+ void delivery.runNow();
14291
+ }
14292
+ catch {
14293
+ // The store could not be read: the 5 s beat still runs; look again shortly.
14294
+ await new Promise(resolveLater => { const timer = setTimeout(resolveLater, 1_000); timer.unref?.(); });
14295
+ }
14296
+ }
14297
+ })();
14298
+ serviceBeats.set(store, beats);
14299
+ void delivery.runNow();
14300
+ return beats;
14301
+ }