toolroll 0.9.77 → 0.9.78

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 (72) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/dist/agent-fence.js +18 -2
  3. package/dist/backup-ui.d.ts +9 -1
  4. package/dist/backup-ui.js +28 -3
  5. package/dist/backup.js +4 -3
  6. package/dist/browser/workspace.css +1 -1
  7. package/dist/browser/workspace.js +18 -18
  8. package/dist/browser-workspace.d.ts +2 -0
  9. package/dist/builder.d.ts +4 -3
  10. package/dist/builder.js +14 -8
  11. package/dist/check-failure.js +31 -3
  12. package/dist/check-report.d.ts +18 -3
  13. package/dist/check-report.js +67 -7
  14. package/dist/claim.d.ts +9 -0
  15. package/dist/claim.js +9 -1
  16. package/dist/contracts/console-api.d.ts +1 -1
  17. package/dist/contracts/console-api.js +1 -1
  18. package/dist/contracts/release-terms.d.ts +41 -0
  19. package/dist/contracts/release-terms.js +53 -0
  20. package/dist/controller-service.js +4 -1
  21. package/dist/deploy-rescue.d.ts +41 -0
  22. package/dist/deploy-rescue.js +94 -0
  23. package/dist/desktop-update.d.ts +1 -1
  24. package/dist/dispose.js +4 -3
  25. package/dist/evidence.d.ts +9 -0
  26. package/dist/evidence.js +15 -3
  27. package/dist/exec.d.ts +2 -0
  28. package/dist/exec.js +15 -1
  29. package/dist/guides.js +18 -8
  30. package/dist/latency-timeline.d.ts +46 -0
  31. package/dist/latency-timeline.js +120 -0
  32. package/dist/live-backup.d.ts +39 -0
  33. package/dist/live-backup.js +271 -0
  34. package/dist/operate.js +203 -16
  35. package/dist/planner.js +2 -2
  36. package/dist/release-adapters.d.ts +32 -1
  37. package/dist/release-adapters.js +99 -7
  38. package/dist/release-carry.d.ts +21 -1
  39. package/dist/release-carry.js +22 -0
  40. package/dist/release-cli.d.ts +3 -0
  41. package/dist/release-cli.js +74 -5
  42. package/dist/release-gate.d.ts +5 -0
  43. package/dist/release-gate.js +1 -1
  44. package/dist/release-recheck.js +3 -2
  45. package/dist/release.d.ts +37 -4
  46. package/dist/release.js +128 -43
  47. package/dist/runner-process.d.ts +67 -0
  48. package/dist/runner-process.js +219 -0
  49. package/dist/runner.d.ts +48 -6
  50. package/dist/runner.js +71 -8
  51. package/dist/scope.d.ts +19 -1
  52. package/dist/scope.js +19 -2
  53. package/dist/scout.js +2 -2
  54. package/dist/serve.js +11 -0
  55. package/dist/server/http.d.ts +2 -0
  56. package/dist/server/pages.js +7 -3
  57. package/dist/server/remote-hooks.d.ts +1 -1
  58. package/dist/server/remote-hooks.js +5 -3
  59. package/dist/server/render-results.d.ts +2 -0
  60. package/dist/server/render-results.js +1 -0
  61. package/dist/server/request-context.d.ts +2 -0
  62. package/dist/server/settings.js +22 -2
  63. package/dist/server/tasks.js +4 -1
  64. package/dist/storage-sweep.js +1 -1
  65. package/dist/store.d.ts +125 -2
  66. package/dist/store.js +250 -12
  67. package/dist/surface.js +3 -1
  68. package/dist/teardown-git.js +5 -5
  69. package/dist/toolroll-update.js +2 -2
  70. package/dist/up-role-child.js +6 -2
  71. package/dist/workspace-revision.js +3 -1
  72. package/package.json +1 -1
package/dist/runner.js CHANGED
@@ -26,6 +26,7 @@ import { inspectAcpAgent, inspectionWords } from "./acp-inspect.js";
26
26
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
27
27
  import { resolve } from "node:path";
28
28
  import { canonicalProject } from "./project.js";
29
+ import { lifecycleOf, reportLifecycle } from "./store.js";
29
30
  import { ALL_CREDENTIAL_ENV, PROVIDER_IDS, inspectionOf } from "./provider.js";
30
31
  import { attestationOf, versionInRange } from "./attest.js";
31
32
  /** A runner's repo binding, in the same canonical form task filing uses —
@@ -39,11 +40,18 @@ export const RUNNER_TOKEN_MS = 365 * 86_400_000;
39
40
  /**
40
41
  * How long a runner may go quiet before it is presumed gone.
41
42
  *
42
- * Comfortably more than any sensible heartbeat interval: declaring a busy
43
- * machine dead because one check-in was slow costs more than waiting another
44
- * minute for a machine that really is.
43
+ * Four heartbeats (RUNNER_HEARTBEAT_MS): one slow check-in never declares a busy
44
+ * machine dead, and a machine that really is gone holds its name for a minute,
45
+ * not three (docs/plans/actors-lessons.md). A clean stop doesn't wait even that
46
+ * long: a draining or stopped holder never blocks the next start
47
+ * (registerRunnerIfIdle).
45
48
  */
46
- export const DEFAULT_LIVENESS_MS = 3 * 60_000;
49
+ export const DEFAULT_LIVENESS_MS = 60_000;
50
+ /** How often a live runner says so: `up`'s runner heartbeat and every watch lease renewal. */
51
+ export const RUNNER_HEARTBEAT_MS = 15_000;
52
+ /** The longest a watch lease may be taken or renewed for (durable-actors' MAX_HOST_LEASE_DURATION_MS): a crashed
53
+ * holder's projects are free within a minute. The store clamps every ttl to it. */
54
+ export { MAX_WATCH_LEASE_MS } from "./store.js";
47
55
  export function register(store, options) {
48
56
  const { name, host, capacity = 1, repos = [], agents = [], now, newToken = mintToken, mutation = {}, } = options;
49
57
  const token = newToken();
@@ -78,6 +86,9 @@ export function register(store, options) {
78
86
  // Doing it from the `previous` snapshot above is racy: two registrars can
79
87
  // read the same predecessor and otherwise mint the same generation.
80
88
  const runner = store.saveRunner(proposed, hashToken(token), mutation);
89
+ // v137: this registration is started; its lifecycle row is bound to it by registered_at, so an earlier holder's
90
+ // drain or stop never describes it.
91
+ reportLifecycle(store, workerComponent(name), "started", runner.registeredAt, now);
81
92
  return {
82
93
  runner,
83
94
  token,
@@ -204,9 +215,30 @@ export function normalizeRunnerName(raw) {
204
215
  * the other gets the typed refusal.
205
216
  */
206
217
  export function registerRunnerIfIdle(store, options) {
207
- const { name, now } = options;
218
+ const { name, now, previousGone, ...registration } = options;
208
219
  return store.transact(() => {
209
- const existing = store.getRunner(name);
220
+ let existing = store.getRunner(name);
221
+ // The protection against two live holders stands: only a holder whose every process is proved gone, or whose own
222
+ // drain has ended (its stop bound passed, or it reported stopped), is signed off.
223
+ let signedOff = null;
224
+ const blocked = existing !== null && ((existing.runner.retiredAt === null && isAlive(existing.runner, now)) || store.liveWatchLeaseOf(name, now) !== null);
225
+ const drain = existing === null ? null : drainOf(store, existing.runner, now);
226
+ // A scheduled drain deadline is not proof that its timer ran or its children exited.
227
+ if (blocked && existing !== null && previousGone !== undefined) {
228
+ const proof = previousGone(existing);
229
+ if (proof.gone) {
230
+ store.retireRunner(name, now);
231
+ store.expireWatchLeasesOf(name, now);
232
+ store.recordAction({ at: now.toISOString(), actor: "toolroll up", repo: null, taskId: null, runId: null,
233
+ action: "worker signed off", outcome: "processes proved gone", source: "policy", detail: proof.detail });
234
+ signedOff = proof.detail;
235
+ existing = store.getRunner(name);
236
+ }
237
+ }
238
+ // A live draining holder remains exclusive even if its advertised deadline has passed.
239
+ if (existing !== null && drain !== null && (isAlive(existing.runner, now) || store.liveWatchLeaseOf(name, now) !== null)) {
240
+ return { ok: false, reason: "runner-draining", until: drain.until, detail: `Toolroll is restarting (draining): ${name} is still stopping (scheduled by ${drain.until})` };
241
+ }
210
242
  if (existing !== null && existing.runner.retiredAt === null && isAlive(existing.runner, now)) {
211
243
  return {
212
244
  ok: false,
@@ -227,8 +259,8 @@ export function registerRunnerIfIdle(store, options) {
227
259
  // otherwise hide it from every later recovery.
228
260
  const recoveredRuns = existing === null ? 0 : store.recoverRunnerWork(name, now).runs.length;
229
261
  // A restart keeps the capacity an operator set; only an explicit one changes it.
230
- const registration = register(store, { ...options, capacity: options.capacity ?? existing?.runner.capacity ?? 1 });
231
- return { ok: true, ...registration, recoveredRuns };
262
+ const registered = register(store, { ...registration, name, now, capacity: options.capacity ?? existing?.runner.capacity ?? 1 });
263
+ return { ok: true, ...registered, recoveredRuns, signedOff };
232
264
  });
233
265
  }
234
266
  /**
@@ -282,6 +314,10 @@ export function retireRunnerIfCurrent(store, name, token, now) {
282
314
  if (!auth.ok)
283
315
  return { ok: false, reason: auth.reason };
284
316
  store.retireRunner(name, now);
317
+ // Stopped is retired: its leases lapse now, so the next start or watch takes its projects over (recovering whatever
318
+ // this incarnation left) without waiting for them to run out.
319
+ store.expireWatchLeasesOf(name, now);
320
+ reportLifecycle(store, workerComponent(name), "stopped", auth.runner.registeredAt, now);
285
321
  return { ok: true };
286
322
  });
287
323
  }
@@ -435,3 +471,30 @@ export function reportProviderReadinessAuthed(store, args, now) {
435
471
  return auth;
436
472
  });
437
473
  }
474
+ // ---- v137 lifecycle (docs/plans/actors-lessons.md): last in the file, so the reviewed function positions stay ----
475
+ /** A worker's lifecycle row (service_lifecycle). */
476
+ export const workerComponent = (name) => `worker:${name}`;
477
+ /**
478
+ * The stop begins (durable-actors' draining report), fenced by this holder's credential: from now this runner takes
479
+ * no new work (claim.ts acquire refuses). `until` is a schedule, never proof that the holder exited.
480
+ */
481
+ export function drainRunnerIfCurrent(store, name, token, now, until) {
482
+ return store.transact(() => {
483
+ const auth = authenticate(store, name, token);
484
+ if (!auth.ok)
485
+ return false;
486
+ return reportLifecycle(store, workerComponent(name), "draining", auth.runner.registeredAt, now, { drainUntil: until });
487
+ });
488
+ }
489
+ /** A runner's drain as its own lifecycle row reports it (only the current registration's row counts): null when it
490
+ * isn't draining; ended once its bound passed or it reported stopped. */
491
+ export function drainOf(store, runner, now) {
492
+ const row = lifecycleOf(store, workerComponent(runner.name));
493
+ if (row === null || row.holder !== runner.registeredAt)
494
+ return null;
495
+ if (row.state === "stopped")
496
+ return { until: row.changedAt, ended: true };
497
+ if (row.state !== "draining" || row.drainUntil === null)
498
+ return null;
499
+ return { until: row.drainUntil, ended: Date.parse(row.drainUntil) <= now.getTime() };
500
+ }
package/dist/scope.d.ts CHANGED
@@ -412,6 +412,9 @@ export type Scope = SavedScopeTerms & {
412
412
  * carry policy, grant, check access, expiry. Part of the digest when present; a person's seal of them opens the
413
413
  * release's authority record. */
414
414
  releaseTerms?: ReleaseTerms | null;
415
+ /** v138 (docs/plans/pipeline-speed.md, P0): a release check's purpose when the release can't carry (no terms) — this
416
+ * exact candidate is a release's check. Part of the digest when present; never present beside terms. */
417
+ releaseCheck?: ReleaseCheckPurpose | null;
415
418
  };
416
419
  /** Who wrote a scope's text, when not a person (approval-policy plan §1.4). */
417
420
  export type ScopeAuthor = "mate" | "coordinator" | "scout" | "lead";
@@ -464,6 +467,9 @@ export type ScopeInput = {
464
467
  /** v135: release terms (release-carry.ts releaseTermsFor), from the plane's own records. Undefined keeps any terms
465
468
  * the scope already carries; null removes them. */
466
469
  releaseTerms?: ReleaseTerms | null;
470
+ /** v138: a release check's purpose (release-carry.ts releaseCheckPurposeFor), from the plane's own records. Undefined
471
+ * keeps any purpose the scope already carries; null removes it. */
472
+ releaseCheck?: ReleaseCheckPurpose | null;
467
473
  now: Date;
468
474
  mutation?: Mutation;
469
475
  };
@@ -488,6 +494,8 @@ export declare function digestOf(scope: Pick<Scope, "goal" | "outOfScope" | "tou
488
494
  candidate?: string | null;
489
495
  /** v135: release terms, folded in only when present. */
490
496
  releaseTerms?: ReleaseTerms | null;
497
+ /** v138: a release check's purpose, folded in only when present. */
498
+ releaseCheck?: ReleaseCheckPurpose | null;
491
499
  }, target?: ExecutionProfile | null | {
492
500
  chain: readonly ChainEntry[];
493
501
  }, route?: PhaseRoute | null): string;
@@ -864,10 +872,12 @@ export declare function scopeRouteWords(scope: Pick<Scope, "proposedRouteJson">,
864
872
  export declare function describeScope(scope: Scope, readiness?: ReadinessLookup): string[];
865
873
  /** The approval sheet's release lines (release-once.md): the release in one sentence, then what asks again. */
866
874
  export declare function releaseTermsSentence(terms: ReleaseTerms): string;
875
+ /** The approval sheet's line for a release check purpose (pipeline-speed.md, P0): a release that can't carry. */
876
+ export declare function releaseCheckSentence(purpose: ReleaseCheckPurpose): string;
867
877
  /** What asks again, in full: the terms behind the sentence. */
868
878
  export declare function releaseAsksAgainWords(terms: ReleaseTerms): string[];
869
879
  export { personProof } from "./person-proof.js";
870
- import { type ReleaseTerms } from "./contracts/release-terms.js";
880
+ import { type ReleaseCheckPurpose, type ReleaseTerms } from "./contracts/release-terms.js";
871
881
  import { type AutoEvidence, type ReleaseAuthority, type ReleaseCarry } from "./release-authority.js";
872
882
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
873
883
  export declare const machineActing: () => boolean;
@@ -955,6 +965,14 @@ export declare function mainDebtProblem(store: Store, repo: string, taskId: stri
955
965
  * chat it is approved only through the release card.
956
966
  */
957
967
  export declare function deployGateScope(scope: Pick<Scope, "candidate" | "releaseTerms">): boolean;
968
+ /**
969
+ * Whether a scope's check is a release's check (docs/plans/pipeline-speed.md, P0): it carries signed release terms, or a
970
+ * release check purpose sealed with it. Read from the scope's signed fields only, independent of whether the release can
971
+ * carry. It makes the check stricter (a complete, valid report; CI never stands in for its units) and, through
972
+ * check-failure.ts recheckReleaseTask, lets the plane rerun the identical candidate after confirmed infrastructure; it
973
+ * never carries a later commit or approves anything.
974
+ */
975
+ export declare function releaseCheckScope(scope: Pick<Scope, "releaseTerms" | "releaseCheck"> | null): boolean;
958
976
  /**
959
977
  * The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
960
978
  * belts read it (release-authority.ts autoSealSql): sealed `kind` on the current digest, its evidence row naming exactly
package/dist/scope.js CHANGED
@@ -619,6 +619,7 @@ route) {
619
619
  ...(scope.qualityMode === "strict" ? { qualityMode: "strict" } : {}),
620
620
  ...(scope.candidate ? { candidate: scope.candidate } : {}),
621
621
  ...(scope.releaseTerms == null ? {} : { releaseTerms: releaseTermsDigest(scope.releaseTerms) }),
622
+ ...(scope.releaseCheck == null ? {} : { releaseCheck: releaseCheckPurposeDigest(scope.releaseCheck) }),
622
623
  // The single outer key, whichever target produced its inner value:
623
624
  // absent => the golden and every profileless approval are untouched;
624
625
  // a legacy profile => its exact profileDigestOf; an explicit chain
@@ -672,6 +673,7 @@ function proposeLocked(store, input) {
672
673
  ...(input.qualityMode === undefined ? {} : { qualityMode: input.qualityMode }),
673
674
  ...(posture === undefined ? {} : { posture }),
674
675
  ...(input.releaseTerms === undefined ? {} : { releaseTerms: input.releaseTerms }),
676
+ ...(input.releaseCheck === undefined ? {} : { releaseCheck: input.releaseCheck }),
675
677
  proposedVia,
676
678
  });
677
679
  // The store may have RECOMPUTED the digest to bind the resolved profile
@@ -1265,7 +1267,7 @@ export function scopeAuthorityOf(scope, env = {}) {
1265
1267
  const parity = routeParityProblem(route, profile, { riskLevel: scope.riskLevel ?? "routine", qualityMode: scope.qualityMode ?? "default" });
1266
1268
  if (parity !== null)
1267
1269
  return { ok: false, reason: "parity", problem: parity };
1268
- const digest = digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null }, profile, route);
1270
+ const digest = digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null, releaseCheck: scope.releaseCheck ?? null }, profile, route);
1269
1271
  if (digest !== scope.digest) {
1270
1272
  return { ok: false, reason: "digest", problem: "the scope's reference does not re-derive from its own terms, agents, and route — file it again" };
1271
1273
  }
@@ -1325,6 +1327,7 @@ export function describeScope(scope, readiness = NO_READINESS) {
1325
1327
  // Its sandbox terms (project-sandbox.ts), part of what the yes agrees to: what it reads, and any widening.
1326
1328
  ...(scope.profile === null || scope.profile === undefined ? [] : ((terms) => [` reads ${terms.reads}`, ...(terms.mach === null ? [] : [` services ${terms.mach}`])])(sandboxTermsWords(scope.profile))),
1327
1329
  ...releaseTermsWords(scope.releaseTerms ?? null),
1330
+ ...(scope.releaseCheck == null ? [] : [` release ${releaseCheckSentence(scope.releaseCheck)}`]),
1328
1331
  ` reference ${scope.digest}`,
1329
1332
  ` approved ${describeApproval(approval)}`,
1330
1333
  ];
@@ -1336,6 +1339,10 @@ export function releaseTermsSentence(terms) {
1336
1339
  const outside = terms.access === "sandboxed" ? "" : " This project's checks don't run in the sandbox: those later test-only commits will run outside the sandbox without being shown to you first.";
1337
1340
  return `Release ${terms.packageVersion} from pull request #${terms.pr} at ${terms.approvedSha.slice(0, 7)}. Later commits on this pull request that only change tests, docs or the changelog ship without asking you again, until it merges or for 3 days. You'll see each one in the release summary.${outside}`;
1338
1341
  }
1342
+ /** The approval sheet's line for a release check purpose (pipeline-speed.md, P0): a release that can't carry. */
1343
+ export function releaseCheckSentence(purpose) {
1344
+ return `Release ${purpose.packageVersion} from pull request #${purpose.pr} at ${purpose.approvedSha.slice(0, 7)}. Every later commit asks for your approval again. If Toolroll's own machine fails during the check, it reruns this same commit's check (at most twice).`;
1345
+ }
1339
1346
  /** What asks again, in full: the terms behind the sentence. */
1340
1347
  export function releaseAsksAgainWords(terms) {
1341
1348
  const p = terms.policy;
@@ -1364,7 +1371,7 @@ function describeApproval(approval) {
1364
1371
  // ---- Only a person approves (docs/plans/approval-policy.md, phase 1). Kept last: the freshness tripwire pins places.
1365
1372
  export { personProof } from "./person-proof.js";
1366
1373
  import { personProof } from "./person-proof.js";
1367
- import { releaseTermsDigest } from "./contracts/release-terms.js";
1374
+ import { releaseCheckPurposeDigest, releaseTermsDigest } from "./contracts/release-terms.js";
1368
1375
  import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem } from "./release-authority.js";
1369
1376
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
1370
1377
  export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
@@ -1548,6 +1555,16 @@ export function mainDebtProblem(store, repo, taskId) {
1548
1555
  export function deployGateScope(scope) {
1549
1556
  return (scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null;
1550
1557
  }
1558
+ /**
1559
+ * Whether a scope's check is a release's check (docs/plans/pipeline-speed.md, P0): it carries signed release terms, or a
1560
+ * release check purpose sealed with it. Read from the scope's signed fields only, independent of whether the release can
1561
+ * carry. It makes the check stricter (a complete, valid report; CI never stands in for its units) and, through
1562
+ * check-failure.ts recheckReleaseTask, lets the plane rerun the identical candidate after confirmed infrastructure; it
1563
+ * never carries a later commit or approves anything.
1564
+ */
1565
+ export function releaseCheckScope(scope) {
1566
+ return scope !== null && ((scope.releaseTerms ?? null) !== null || (scope.releaseCheck ?? null) !== null);
1567
+ }
1551
1568
  /**
1552
1569
  * The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
1553
1570
  * belts read it (release-authority.ts autoSealSql): sealed `kind` on the current digest, its evidence row naming exactly
package/dist/scout.js CHANGED
@@ -21,7 +21,7 @@ import { run } from "./exec.js";
21
21
  import { checkoutGit } from "./teardown-git.js";
22
22
  import { auditOf } from "./provider.js";
23
23
  import { currentClaim, heartbeat } from "./claim.js";
24
- import { heartbeat as runnerHeartbeat } from "./runner.js";
24
+ import { heartbeat as runnerHeartbeat, RUNNER_HEARTBEAT_MS } from "./runner.js";
25
25
  import { parseDecision } from "./decision.js";
26
26
  import { scoutHandbackSchema } from "./contracts/scout-report.js";
27
27
  import { parseReport, REPORT_IMAGE_FILE, REPORT_LIMITS, REPORT_VERSION, SCOUT_OUTPUT_JSON_SCHEMA } from "./scout-report.js";
@@ -43,7 +43,7 @@ const GIT = "git";
43
43
  const AGENT_ENV_DENYLIST = [...TELEGRAM_TOKEN_ENVS];
44
44
  const DEFAULT_SCOUT_TIMEOUT_MS = 20 * 60_000;
45
45
  const DEFAULT_SCOUT_TURNS = CLAUDE_LIMITS.maxTurns;
46
- const DEFAULT_PULSE_MS = 60_000;
46
+ const DEFAULT_PULSE_MS = RUNNER_HEARTBEAT_MS;
47
47
  /** The scout's own headless browser: the pinned Playwright tool, saving its
48
48
  * screenshots straight into the run's image folder. */
49
49
  export const SCOUT_BROWSER = "toolroll-browser";
package/dist/serve.js CHANGED
@@ -100,6 +100,7 @@ import { prepareWorkspaceRevision, WorkspaceValidatorCache } from "./workspace-r
100
100
  * repo, only runs whose task belongs to that repo (or to no repo yet).
101
101
  */
102
102
  import { createHash, randomBytes } from "node:crypto";
103
+ import { keepSlowRequest, Timeline } from "./latency-timeline.js";
103
104
  import { readFileSync } from "node:fs";
104
105
  import { createServer } from "node:http";
105
106
  import { homedir, hostname } from "node:os";
@@ -775,6 +776,9 @@ export function createDecisionServer(options) {
775
776
  // uses the address it reached), reads one row, and says nothing else about the installation.
776
777
  const method = request.method ?? "GET";
777
778
  const matchedRoute = matchRoute(method, hook.pathname);
779
+ // Where a slow answer's time went (latency-timeline.ts): milestones by route id only, kept when it took a second.
780
+ const timeline = new Timeline();
781
+ timeline.mark("route matched");
778
782
  const edgeRoute = matchedRoute?.stage === 'edge' ? matchedRoute : null;
779
783
  const wrongMethodEdge = edgeRoute === null ? ROUTES.find(row => row.stage === 'edge' && new RegExp(row.pattern).test(hook.pathname)) ?? null : null;
780
784
  // D7: a password on a request still signs in this release, announced on every answer and named in every refusal;
@@ -837,6 +841,7 @@ export function createDecisionServer(options) {
837
841
  return;
838
842
  const refusedToken = {};
839
843
  const who = identify(request, touch, edgeRoute?.domain === 'live', refusedToken);
844
+ timeline.mark("signed in");
840
845
  if (refusedToken.principal !== undefined) {
841
846
  if (refuseBudget(requestBudget.admit(refusedToken.principal.tokenId, "api")))
842
847
  return;
@@ -883,7 +888,9 @@ export function createDecisionServer(options) {
883
888
  freshWork: ["request", "task", "result"].some(name => url.searchParams.has(name)),
884
889
  ...(workspaceRead && typeof request.headers['if-none-match'] === 'string' ? { workspaceIfNoneMatch: request.headers['if-none-match'] } : {}),
885
890
  workspaceRequest: url.searchParams.get('request'),
891
+ timeline,
886
892
  };
893
+ response.once("finish", () => keepSlowRequest(store, timeline, matchedRoute?.id ?? "unmatched", response.statusCode, clock()));
887
894
  if (method === "GET" || method === "POST")
888
895
  return freshFacts.run(fresh, () => provenPasswordAccount.run(passwordProof, () => requestContext.run(requestFacts, async () => {
889
896
  if (edgeRoute?.domain === 'live')
@@ -937,6 +944,7 @@ export function createDecisionServer(options) {
937
944
  etag: '"' + createHash('sha256').update(`${key}:${revision}:${expiresAt}`).digest('hex') + '"' };
938
945
  }
939
946
  await prefetchChrome(route, url, who);
947
+ timeline.mark("chrome and worker reads");
940
948
  // The worker wait lets a sign-out, credential/access change or console ceiling change land. Prove the
941
949
  // cookie and route again before rendering protected content or answering with a content validator.
942
950
  if (who.via === 'cookie') {
@@ -1454,6 +1462,7 @@ export function createDecisionServer(options) {
1454
1462
  * are what sensitivity strips.
1455
1463
  */
1456
1464
  function sendScreen(response, status, s) {
1465
+ requestContext.getStore()?.timeline?.mark("handler done; rendering");
1457
1466
  // Sensitivity is the page as authored (cockpit §2, R14): its own explicit word, else a password field it was written
1458
1467
  // with — judged before an identity-provider sign-in replaces that field, so an SSO step-up page stays sensitive.
1459
1468
  const authored = s.sensitive ?? (s.forceSensitive === true ||
@@ -1486,6 +1495,8 @@ export function createDecisionServer(options) {
1486
1495
  notices.push(s.chrome.modeBanner.words);
1487
1496
  if (s.chrome.updateWaiting)
1488
1497
  notices.push(s.chrome.updateWaiting.words);
1498
+ if (options.draining?.() === true)
1499
+ notices.push('Toolroll is restarting (draining). Saved work is kept; new builds start once it is back.');
1489
1500
  let crew = { crew: [], crewTruncated: false };
1490
1501
  try {
1491
1502
  const project = s.chrome.active === 'chat' ? null : s.chrome.project;
@@ -148,6 +148,8 @@ export type ServeOptions = {
148
148
  ssoFetch?: typeof fetch;
149
149
  /** Cockpit B1: this `up` console's instance secret (null: none — the instance route answers 404). */
150
150
  instanceSecret?: () => string | null;
151
+ /** v137: whether this console is draining (its `up` is stopping): /healthz says so at once, and pages say Toolroll is restarting. */
152
+ draining?: () => boolean;
151
153
  /** Cockpit E: how Finder is asked to reveal a proved checkout (tests pass their own; never a launch). True: shown. */
152
154
  revealPath?: (path: string) => Promise<boolean> | boolean;
153
155
  chatEnv?: Record<string, string | undefined>;
@@ -43,6 +43,7 @@ import { healthWords } from "../server-telemetry.js";
43
43
  import { tally } from "../summary.js";
44
44
  import { serveInstallAsset } from "./pages-assets.js";
45
45
  import { healthz } from "./remote-hooks.js";
46
+ import { slowRequestLines, SLOW_REQUEST_MS } from "../latency-timeline.js";
46
47
  import { leadsStillAdmitted, leadTilesFor } from './agents-live-lead.js';
47
48
  import { refuse, regionScript, screen, settingsRows, WORKSPACE_STYLE, workToolRows } from "./chrome.js";
48
49
  import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
@@ -627,9 +628,12 @@ export function createPagesHandlers(runtime) {
627
628
  async function health(ctx) {
628
629
  const { request, response } = ctx;
629
630
  const health = store.telemetry.snapshot();
631
+ // Beside /healthz, for an instance operator: where the slowest recent console answers spent their time (route ids
632
+ // and milestones only).
633
+ const slow = slowRequestLines(store);
630
634
  return request.headers.accept?.includes('application/json')
631
- ? respond(response, 200, 'application/json; charset=utf-8', JSON.stringify(health))
632
- : respond(response, 200, 'text/plain; charset=utf-8', `${healthWords(health)}\n`);
635
+ ? respond(response, 200, 'application/json; charset=utf-8', JSON.stringify({ ...health, slowRequests: slow }))
636
+ : respond(response, 200, 'text/plain; charset=utf-8', `${healthWords(health)}\n${slow.length === 0 ? "" : `\nSlow console requests (over ${SLOW_REQUEST_MS} ms), newest first:\n${slow.join("\n")}\n`}`);
633
637
  }
634
638
  // v104: Prometheus metrics, for an instance operator (a scraper sends one's API token).
635
639
  async function metrics(ctx) {
@@ -984,7 +988,7 @@ export function createPagesHandlers(runtime) {
984
988
  }
985
989
  async function healthzEdge(ctx) {
986
990
  const { response, method } = ctx;
987
- return healthz(store, response, method === 'HEAD');
991
+ return healthz(store, response, method === 'HEAD', options.draining?.() === true);
988
992
  }
989
993
  async function browserAsset(ctx) {
990
994
  const { request, response, url } = ctx;
@@ -26,4 +26,4 @@ export declare function telegramHook(ctx: RemoteHookContext, request: IncomingMe
26
26
  export declare function flowHook(ctx: RemoteHookContext, request: IncomingMessage, response: ServerResponse, url: URL): Promise<void>;
27
27
  /** A shared button as a public form (v84): its questions, and one card per submission. No sign-in, no session, nothing about the flow shown. */
28
28
  export declare function flowForm(ctx: RemoteHookContext, request: IncomingMessage, response: ServerResponse, url: URL): Promise<void>;
29
- export declare function healthz(store: Store, response: ServerResponse, head: boolean): void;
29
+ export declare function healthz(store: Store, response: ServerResponse, head: boolean, draining?: boolean): void;
@@ -122,7 +122,7 @@ export async function flowForm(ctx, request, response, url) {
122
122
  return page({ status: 500, html: "" });
123
123
  }
124
124
  }
125
- export function healthz(store, response, head) {
125
+ export function healthz(store, response, head, draining = false) {
126
126
  let healthy = true;
127
127
  try {
128
128
  store.handle.prepare("SELECT 1 FROM schema_version").get();
@@ -130,7 +130,9 @@ export function healthz(store, response, head) {
130
130
  catch {
131
131
  healthy = false;
132
132
  }
133
- const body = JSON.stringify({ status: healthy ? "ok" : "unavailable" });
134
- response.writeHead(healthy ? 200 : 503, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
133
+ // A draining console (its `up` is stopping) still answers pages, but says so here at once: a probe moves on instead
134
+ // of waiting for it to disappear.
135
+ const body = JSON.stringify(draining && healthy ? { status: "draining", message: "Toolroll is restarting (draining)" } : { status: healthy ? "ok" : "unavailable" });
136
+ response.writeHead(healthy && !draining ? 200 : 503, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
135
137
  response.end(head ? undefined : body);
136
138
  }
@@ -185,6 +185,8 @@ export type ReviewCockpitView = {
185
185
  efficiency?: RunEfficiency | null;
186
186
  /** What the run's network was and what its proxy saw (egress.ts): display only. */
187
187
  network?: RunNetworkRecord | null;
188
+ /** Where the time went (latency-timeline.ts): its release check's parts and the task's deploys, display only. */
189
+ timing?: import("../latency-timeline.js").TimingView | null;
188
190
  /** How far its checks reached (check-access.ts): shown only when wider than sandboxed. */
189
191
  checkAccess?: CheckAccess;
190
192
  taskId: string;
@@ -625,6 +625,7 @@ export function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab,
625
625
  notes: view.notes.map(one => ({ author: one.author, at: one.createdAt, note: one.note })),
626
626
  efficiency: view.efficiency ?? null,
627
627
  network: view.network ?? null,
628
+ timing: view.timing ?? null,
628
629
  acts: { primary: null, secondary: null, line: null }, runChecks: null, mismatch: null,
629
630
  // The raw run record lives under Details now (2026-10-02): /r/<id> for this result redirects here.
630
631
  record: run === null ? null : { build: run.id, href: `/r/${run.id}?record=1`, facts: [
@@ -18,6 +18,8 @@ export type RequestFacts = {
18
18
  returnTo: string;
19
19
  actor?: string;
20
20
  createdTask?: string;
21
+ /** This request's latency timeline (latency-timeline.ts): kept when the answer was slow. */
22
+ timeline?: import("../latency-timeline.js").Timeline;
21
23
  browser?: boolean;
22
24
  workspaceRead?: boolean;
23
25
  /** Receipt reconciliation, task and result reads (`request`, `task`, `result`): work is read afresh, never cached. */
@@ -117,7 +117,9 @@ import { sweepSchedule } from "../storage-sweep-schedule.js";
117
117
  import { storageHtml } from "../storage-ui.js";
118
118
  import { landedSummary } from "../landed-cleanup.js";
119
119
  import { bytesWords, parseCleanup } from "../storage.js";
120
- import { isDigestTime, RESULT_SCREENSHOTS } from "../store.js";
120
+ import { isDigestTime, liveBackupState, RESULT_SCREENSHOTS, setLiveBackupSettings } from "../store.js";
121
+ import { defaultLiveFolder, liveFolderOf, liveFolderProblem } from "../live-backup.js";
122
+ import { LIVE_EVERY } from "../backup-ui.js";
121
123
  import { checkTeamsCredentials, clearTeamsCredentials, loadTeamsCredentials, saveTeamsCredentials, TeamsError } from "../teams-api.js";
122
124
  import { teamsSettingsHtml } from "../teams-settings.js";
123
125
  import { telegramSettingsHtml } from "../telegram-settings.js";
@@ -497,7 +499,9 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
497
499
  if (databaseFile === null)
498
500
  return refuse(response, who, 403, "An instance operator looks after backups.", "/settings");
499
501
  const settings = store.backupSettings();
500
- const view = { settings, folder: backupFolderOf(settings, databaseFile), defaultFolder: defaultBackupFolder(databaseFile), runs: store.backupRuns(20), csrf: who.session.csrf, now };
502
+ const live = liveBackupState(store);
503
+ const view = { settings, folder: backupFolderOf(settings, databaseFile), defaultFolder: defaultBackupFolder(databaseFile), runs: store.backupRuns(20), csrf: who.session.csrf, now,
504
+ live: { state: live, folder: liveFolderOf(live.settings, databaseFile), defaultFolder: defaultLiveFolder(databaseFile) } };
501
505
  return sendScreen(response, 200, screen("Backups", html `<p><a href="/settings">Settings</a></p><h1>Backups</h1>${(backupHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
502
506
  }
503
507
  // v105: Settings → Data, where an instance operator downloads everything. The download is a POST with the password.
@@ -1325,8 +1329,24 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1325
1329
  if (problem !== null)
1326
1330
  return back("problem", problem);
1327
1331
  }
1332
+ // The continuous copy (live-backup.ts): how often, and a folder that is a plain, private folder of this user's
1333
+ // outside every project and checkout. A form without these fields leaves it as it is.
1334
+ const liveBefore = liveBackupState(store).settings;
1335
+ const liveEvery = body.get("live_every") ?? null;
1336
+ const liveMinutes = Number(liveEvery);
1337
+ if (liveEvery !== null && liveEvery !== "off" && !LIVE_EVERY.includes(liveMinutes))
1338
+ return back("problem", "Choose how often to copy continuously.");
1339
+ const liveTyped = body.get("live_folder") === null ? liveBefore.folder ?? "" : (body.get("live_folder") ?? "").trim();
1340
+ const liveFolder = liveTyped === "" || liveTyped === defaultLiveFolder(databaseFile) ? null : liveTyped;
1341
+ if (liveFolder !== null) {
1342
+ const problem = liveFolderProblem(liveFolder, [...new Set([...managedRepos(), ...store.knownRepos(), ...(options.poolRoot === undefined ? [] : [options.poolRoot])])]);
1343
+ if (problem !== null)
1344
+ return back("problem", problem);
1345
+ }
1328
1346
  const before = store.backupSettings();
1329
1347
  store.setBackupSettings({ enabled: every !== "off", everyHours: every === "off" ? before.everyHours : hours, keep, folder }, who.name, now);
1348
+ setLiveBackupSettings(store, { enabled: liveEvery === null ? liveBefore.enabled : liveEvery !== "off", everyMinutes: liveEvery === null || liveEvery === "off" ? liveBefore.everyMinutes : liveMinutes,
1349
+ keep: liveBefore.keep, folder: liveFolder }, who.name, now);
1330
1350
  return back("said", every === "off" ? "Saved. Scheduled backups are off." : "Saved.");
1331
1351
  }
1332
1352
  // v105: download everything as a .zip. An instance operator, with a step-up; the ledger records it.
@@ -66,6 +66,7 @@ import { boardTickScript, oneLineOf, QUEUE_VIEW, refuse, regionScript, screen, t
66
66
  import { chatReturnWithSaid, matchTaskPath, redirect, respond, reviewHref, safeChatReturn, safeReturn, SAFETY, taskChatHref, taskHref } from "./http.js";
67
67
  import { boardBody, boardWorkers, donePage, inboxFingerprints, inboxPage, newTaskPage, nextPage, parseInboxTab, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, workPage } from "./render-pages.js";
68
68
  import { runEfficiencyOf } from "../run-efficiency.js";
69
+ import { timingFor } from "../latency-timeline.js";
69
70
  import { ACCEPT_ANYWAY_NEEDS_REASON, editorFileHref, proofBundleView, rankReviewQueue, RESULT_REFUSALS, REVIEW_QUEUE_CAP, reviewCockpitDetailParts, reviewCockpitPage, reviewPriorityOf, runFactsFragment, runOutcomeBadge, runPage, RUNS_PAGE, runsPage, withRefusal } from "./render-results.js";
70
71
  import { approvalFormDigest, approveRefusalWords, checkProgressHtml, consentDoorOf, decisionPage, nonceHashOf, PermissionsWouldChange, requirementsFromEditor, resumeDigestOf, taskOf } from "./render-tasks.js";
71
72
  import {} from "./session.js";
@@ -2837,7 +2838,7 @@ export function createTasksHandlers(runtime) {
2837
2838
  ...(store.isDemo() ? { demo: true } : {}),
2838
2839
  };
2839
2840
  if (run === null) {
2840
- return { ...base, run: null, detail: null, reviewRetry: null, review: null, notes: [] };
2841
+ return { ...base, run: null, detail: null, reviewRetry: null, review: null, notes: [], timing: timingFor(store, null, row.taskId) };
2841
2842
  }
2842
2843
  const detail = resultDetailOf(run, who, now);
2843
2844
  return {
@@ -2866,6 +2867,8 @@ export function createTasksHandlers(runtime) {
2866
2867
  } })(),
2867
2868
  // What its network was and what its proxy saw (egress.ts), display only.
2868
2869
  network: store.runNetwork(run.id),
2870
+ // Where the time went: its release check's parts and the task's deploys (latency-timeline.ts), display only.
2871
+ timing: timingFor(store, run.id, row.taskId),
2869
2872
  // How far its checks reached (check-access.ts), display only.
2870
2873
  checkAccess: store.runCheckAccess(run.id),
2871
2874
  reviewRetry: store.reviewRetryStateOf(run.id),
@@ -8,7 +8,7 @@
8
8
  * Nothing live work uses goes (`liveWork`): while a run is open, a lease current or a checkout in use, each removal
9
9
  * and each signal first asks again whether that work uses the folder, and when its processes can't be read nothing
10
10
  * goes. Every scan and removal runs in a separate child: synchronous disk work must never delay the worker's lease
11
- * heartbeats (90 s). On Oct 12 the sweep starved those heartbeats and three builds ended interrupted half a second
11
+ * heartbeats (a lease lasts a minute). On Oct 12 the sweep starved those heartbeats and three builds ended interrupted half a second
12
12
  * after it finished. Once a run has ended, the next sweep removes what
13
13
  * it no longer uses.
14
14
  *