toolroll 0.9.97 → 0.9.98

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 (78) hide show
  1. package/CHANGELOG.md +10 -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/contracts/chat-actions.d.ts +6 -0
  17. package/dist/contracts/chat-actions.js +2 -0
  18. package/dist/contracts/cli.d.ts +6 -0
  19. package/dist/contracts/cli.js +3 -0
  20. package/dist/contracts/lead-tools.d.ts +17 -0
  21. package/dist/contracts/lead-tools.js +4 -1
  22. package/dist/contracts/store-json.d.ts +1 -0
  23. package/dist/desktop-update.d.ts +1 -1
  24. package/dist/fast-start.d.ts +83 -6
  25. package/dist/fast-start.js +112 -15
  26. package/dist/home-groups.d.ts +39 -0
  27. package/dist/home-groups.js +33 -0
  28. package/dist/lead-answer-eval.d.ts +7 -0
  29. package/dist/lead-answer-eval.js +6 -2
  30. package/dist/lead-capabilities.js +1 -1
  31. package/dist/lead-context.js +2 -2
  32. package/dist/lead-contract.d.ts +1 -1
  33. package/dist/lead-contract.js +10 -7
  34. package/dist/lead-doors.d.ts +32 -0
  35. package/dist/lead-doors.js +80 -14
  36. package/dist/lead-status.d.ts +3 -0
  37. package/dist/lead-status.js +8 -3
  38. package/dist/lead-tools.d.ts +20 -0
  39. package/dist/lead-tools.js +66 -3
  40. package/dist/lead-voice.js +1 -1
  41. package/dist/lead.js +18 -2
  42. package/dist/local-owner.js +2 -0
  43. package/dist/one-step-chat-schema.d.ts +22 -0
  44. package/dist/one-step-chat-schema.js +37 -0
  45. package/dist/operate.d.ts +27 -1
  46. package/dist/operate.js +206 -32
  47. package/dist/project-delete.js +9 -0
  48. package/dist/project-knowledge.d.ts +7 -7
  49. package/dist/publish.d.ts +2 -0
  50. package/dist/publish.js +1 -1
  51. package/dist/pull-request-flow.d.ts +4 -0
  52. package/dist/pull-request-flow.js +16 -1
  53. package/dist/reply-voice.d.ts +23 -0
  54. package/dist/reply-voice.js +37 -0
  55. package/dist/scope.d.ts +6 -1
  56. package/dist/scope.js +3 -2
  57. package/dist/server/chat.js +4 -4
  58. package/dist/server/chrome.js +2 -2
  59. package/dist/server/home.d.ts +4 -3
  60. package/dist/server/home.js +33 -28
  61. package/dist/server/render-chat.js +11 -6
  62. package/dist/server/render-pages.js +2 -2
  63. package/dist/setup-guide.d.ts +2 -2
  64. package/dist/store.d.ts +4 -2
  65. package/dist/store.js +35 -12
  66. package/dist/telegram-lead.js +69 -5
  67. package/dist/telegram-status.js +9 -4
  68. package/dist/telegram.d.ts +10 -0
  69. package/dist/telegram.js +92 -8
  70. package/dist/toolroll-update.js +3 -1
  71. package/dist/tui-contract.d.ts +2 -0
  72. package/dist/tui-source.js +3 -0
  73. package/dist/tui.js +41 -41
  74. package/dist/typed-start.d.ts +51 -0
  75. package/dist/typed-start.js +98 -0
  76. package/dist/wake-hub.d.ts +72 -0
  77. package/dist/wake-hub.js +151 -0
  78. package/package.json +1 -1
@@ -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
+ }
@@ -239,6 +239,15 @@ function deleteRows(store, repo, d, now) {
239
239
  del("chat_decide_action", IN("task_id"), K);
240
240
  del("chat_decide_prompt", IN("task_id"), K);
241
241
  del("chat_message_ref", `${IN("decision")} OR ${IN("task_id")} OR ${IN("run")} OR project = ? OR ${IN("notification")} OR ${IN("task_ref")}`, decisions, K, R, repo, notifications, T);
242
+ // A typed yes to a Start card (one-step-chat.md C5). Its record goes with its message. Where the message stays (it
243
+ // named no task of this project) but the card's proposal goes, the record is settled instead and forgets the task: a
244
+ // yes still waiting is then answered by Toolroll as one that started nothing, never handed to the lead.
245
+ del("chat_typed_start", `provider || ':' || event IN (SELECT value FROM json_each(?))`, conversations);
246
+ if (tableExists(db, "chat_typed_start")) {
247
+ db.prepare(`UPDATE chat_typed_start SET outcome = 'not-counted', task_id = NULL, finished = COALESCE(finished, ?) WHERE ${IN("proposal")}`).run(now.toISOString(), proposals);
248
+ // A yes that started one of this project's tasks, said in a message that stays: the record forgets the task.
249
+ db.prepare(`UPDATE chat_typed_start SET task_id = NULL WHERE ${IN("task_id")}`).run(K);
250
+ }
242
251
  del("chat_part", `provider || ':' || id IN (${parts})`, conversations, K, R);
243
252
  del("chat_event", `provider || ':' || id IN (SELECT value FROM json_each(?))`, conversations);
244
253
  del("chat_card_task", IN("task_ref"), T);
@@ -22,17 +22,20 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
22
22
  export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
+ content?: never;
26
+ history?: never;
25
27
  revision: number;
26
28
  stale: boolean;
27
29
  notice: string;
28
30
  title?: never;
29
- content?: never;
30
- history?: never;
31
31
  instructions?: never;
32
32
  references?: never;
33
33
  decisions?: never;
34
34
  waiting?: never;
35
35
  } | {
36
+ content?: never;
37
+ revision?: never;
38
+ history?: never;
36
39
  stale?: never;
37
40
  decision: {
38
41
  repo: string;
@@ -51,28 +54,25 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
51
54
  afterwards?: string;
52
55
  notice: string;
53
56
  title?: never;
54
- content?: never;
55
- revision?: never;
56
- history?: never;
57
57
  instructions?: never;
58
58
  references?: never;
59
59
  decisions?: never;
60
60
  waiting?: never;
61
61
  } | {
62
+ history?: never;
62
63
  stale?: never;
63
64
  revision: number;
64
65
  title: string;
65
66
  content: string;
66
67
  notice: string;
67
- history?: never;
68
68
  instructions?: never;
69
69
  references?: never;
70
70
  decisions?: never;
71
71
  waiting?: never;
72
72
  } | {
73
+ content?: never;
73
74
  stale?: never;
74
75
  title?: never;
75
- content?: never;
76
76
  revision: number;
77
77
  history: {
78
78
  revision: number;
package/dist/publish.d.ts CHANGED
@@ -107,6 +107,8 @@ export type ObservedPullRequest = {
107
107
  export declare function observeChecks(store: Store, options: {
108
108
  clock?: () => Date;
109
109
  exec?: PublishExec;
110
+ /** Read only these (the fast pass's waiting pull requests, live-wakeups.md); every one is read exactly as below. */
111
+ publications?: readonly Publication[];
110
112
  }): Promise<CheckReport>;
111
113
  export declare const MERGE_LEASE_MS: number;
112
114
  export declare const FIRING_GRACE_MS: number;
package/dist/publish.js CHANGED
@@ -304,7 +304,7 @@ export async function observeChecks(store, options) {
304
304
  const clock = options.clock ?? (() => new Date());
305
305
  const exec = options.exec ?? execRun;
306
306
  const report = { observed: 0, failing: 0, resolved: 0, problems: [], seen: [] };
307
- for (const publication of store.openedPublications()) {
307
+ for (const publication of options.publications ?? store.openedPublications()) {
308
308
  if (publication.prNumber === null)
309
309
  continue;
310
310
  // The observation generation is RESERVED before the network call
@@ -129,6 +129,8 @@ export declare function followPullRequests(store: Store, seen: readonly Observed
129
129
  evidenceRoot: string;
130
130
  exec?: PublishExec;
131
131
  clock?: () => Date;
132
+ /** Asked again before each revision, ready mark and merge: false ends the pass there and writes nothing more. */
133
+ proceed?: () => boolean;
132
134
  }): Promise<FollowReport>;
133
135
  /** A person's merge: their password first, then the same checked merge. */
134
136
  export declare function mergeAsPerson(store: Store, input: {
@@ -156,6 +158,8 @@ export declare function mergePullRequest(store: Store, input: {
156
158
  method?: MergeMethod;
157
159
  /** Only a person merging by hand may skip waiting for the full check. */
158
160
  anyway?: boolean;
161
+ /** An automatic pass's standing, read in the write that reserves the merge: false refuses ("halted") and merges nothing. */
162
+ proceed?: () => boolean;
159
163
  }): Promise<Result<{
160
164
  commit: string | null;
161
165
  }>>;
@@ -285,7 +285,10 @@ export async function followPullRequests(store, seen, options) {
285
285
  const exec = options.exec ?? execRun;
286
286
  const clock = options.clock ?? (() => new Date());
287
287
  const report = { revisions: 0, asked: 0, ready: 0, merged: 0, problems: [] };
288
+ const halted = () => options.proceed?.() === false;
288
289
  for (const one of seen) {
290
+ if (halted())
291
+ break;
289
292
  const publication = one.publication;
290
293
  const follow = followOf(store, publication.id);
291
294
  // A flow's Pull request zone follows its own PR: green moves the card on, red takes its failure path.
@@ -313,6 +316,8 @@ export async function followPullRequests(store, seen, options) {
313
316
  const check = failingChecks(one.rollup)[0] ?? { name: "a check", url: null };
314
317
  const skeleton = ciRevisionNote(check.name, pr, one.headOid, "");
315
318
  const excerpt = await logExcerpt(exec, publication.githubRepo, check.url, 490 - skeleton.length);
319
+ if (halted())
320
+ break;
316
321
  const note = ciRevisionNote(check.name, pr, one.headOid, excerpt);
317
322
  const request = createHash("sha256").update(`ci-revision ${publication.githubRepo} ${pr} ${one.headOid}`).digest("hex").slice(0, 32);
318
323
  const scope = store.getScope(taskId);
@@ -348,11 +353,17 @@ export async function followPullRequests(store, seen, options) {
348
353
  const grant = store.publicationGrantFor(store.refById(publication.taskRef)?.repo ?? "");
349
354
  updateFollow(store, publication.id, { ready_head: one.headOid }, clock());
350
355
  if (grant?.mergeWhenGreen === true && grant.publishOn === "complete") {
351
- const merged = await mergePullRequest(store, { runId: publication.run, by: "merge when checks pass", exec, clock });
356
+ const merged = await mergePullRequest(store, { runId: publication.run, by: "merge when checks pass", exec, clock,
357
+ ...(options.proceed === undefined ? {} : { proceed: options.proceed }) });
352
358
  if (merged.ok) {
353
359
  report.merged++;
354
360
  continue;
355
361
  }
362
+ // Halted before the merge was reserved: the ready mark is taken back so the next pass merges it, and nobody is asked to.
363
+ if (merged.reason === "halted") {
364
+ updateFollow(store, publication.id, { ready_head: follow.readyHead }, clock());
365
+ break;
366
+ }
356
367
  report.problems.push(`PR #${pr}: ${merged.message}`);
357
368
  }
358
369
  store.enqueueNotification({
@@ -485,6 +496,8 @@ export async function mergePullRequest(store, input) {
485
496
  // One merge at a time, each owning its reservation by its token: a second caller is refused, and each releases only its own.
486
497
  const token = randomUUID();
487
498
  const reserved = store.transact(() => {
499
+ if (input.proceed?.() === false)
500
+ return "halted";
488
501
  if (refArchived(store, publication.taskRef))
489
502
  return "archived";
490
503
  if (store.handle.prepare("SELECT 1 FROM hold WHERE task_ref = ? AND owner_id LIKE 'project-delete:%' AND (until IS NULL OR until > ?)").get(publication.taskRef, clock().toISOString()) !== undefined)
@@ -494,6 +507,8 @@ export async function mergePullRequest(store, input) {
494
507
  store.handle.prepare("INSERT OR REPLACE INTO merge_in_flight (publication, task_ref, token, by, at) VALUES (?, ?, ?, ?, ?)").run(publication.id, publication.taskRef, token, input.by, clock().toISOString());
495
508
  return "ok";
496
509
  });
510
+ if (reserved === "halted")
511
+ return refuse("halted", "The worker is stopping or paused, so this merge waits for its next pass.");
497
512
  if (reserved === "archived")
498
513
  return refuse("archived", "This task is put away. Restore it before merging.");
499
514
  if (reserved === "deleting")
@@ -0,0 +1,23 @@
1
+ export type ReplyVoice = {
2
+ /** Characters of the shaped reply. */
3
+ characters: number;
4
+ sentences: number;
5
+ /** The `PLAIN_WORDS` rows said outside code, plus "owner" when the reply calls the person "the owner". */
6
+ internalWords: string[];
7
+ /** Opens with its own steps or a filler word ("I checked", "Let me", "Sure,") instead of the answer. */
8
+ narratedOpening: boolean;
9
+ };
10
+ /** A reply longer than this is counted as long in the eval's report. */
11
+ export declare const LONG_REPLY_CHARACTERS = 320;
12
+ export declare function replyVoice(reply: string): ReplyVoice;
13
+ export type VoiceSummary = {
14
+ answers: number;
15
+ medianCharacters: number;
16
+ /** Share of answers over `LONG_REPLY_CHARACTERS`, from 0 to 1. */
17
+ longShare: number;
18
+ /** Internal words said, summed over the answers. */
19
+ internalWords: number;
20
+ narratedOpenings: number;
21
+ };
22
+ /** One model's answers, summed up for the report. */
23
+ export declare function summarizeVoice(voices: readonly ReplyVoice[]): VoiceSummary;