toolroll 0.9.98 → 0.9.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/acp-check.js +4 -2
  3. package/dist/agent-state.js +2 -1
  4. package/dist/agents-board.js +3 -2
  5. package/dist/agents-live.js +3 -2
  6. package/dist/approval-authority.d.ts +2 -1
  7. package/dist/approval-authority.js +2 -1
  8. package/dist/chat-ask.js +4 -2
  9. package/dist/chat-core.js +4 -2
  10. package/dist/chat-decide.js +1 -2
  11. package/dist/chat-delivery-state.d.ts +29 -6
  12. package/dist/chat-delivery-state.js +43 -13
  13. package/dist/chat-delivery.js +12 -5
  14. package/dist/chat-flow.js +15 -13
  15. package/dist/chat-live.js +5 -2
  16. package/dist/chat-message-states.d.ts +75 -0
  17. package/dist/chat-message-states.js +74 -0
  18. package/dist/chat-messages.d.ts +10 -4
  19. package/dist/chat-messages.js +12 -7
  20. package/dist/check-failure.js +2 -1
  21. package/dist/check-report.js +1 -1
  22. package/dist/checkout-cleanup.js +2 -1
  23. package/dist/claim.d.ts +40 -2
  24. package/dist/claim.js +92 -19
  25. package/dist/contracts/chat-tables.js +3 -0
  26. package/dist/coordinator.js +3 -3
  27. package/dist/demo.js +7 -2
  28. package/dist/deploy-phase-data.json +21 -0
  29. package/dist/deploy-phase.d.ts +37 -0
  30. package/dist/deploy-phase.js +64 -0
  31. package/dist/deploy-rescue.d.ts +2 -1
  32. package/dist/deploy-rescue.js +6 -7
  33. package/dist/discord-chat.js +2 -3
  34. package/dist/dispatch.js +2 -1
  35. package/dist/dispose.d.ts +4 -1
  36. package/dist/dispose.js +2 -1
  37. package/dist/fallback.js +3 -2
  38. package/dist/fast-start.js +2 -2
  39. package/dist/monitoring.js +2 -1
  40. package/dist/operate.js +75 -36
  41. package/dist/plane-review.js +2 -1
  42. package/dist/project-knowledge.d.ts +3 -3
  43. package/dist/release-adapters.js +30 -14
  44. package/dist/release-authority.d.ts +9 -0
  45. package/dist/release-authority.js +13 -0
  46. package/dist/release-auto.js +2 -3
  47. package/dist/release-gate.d.ts +3 -3
  48. package/dist/release-gate.js +8 -5
  49. package/dist/release-next.d.ts +3 -3
  50. package/dist/release-next.js +13 -16
  51. package/dist/release-view.js +2 -1
  52. package/dist/release.js +18 -30
  53. package/dist/result-shots.js +1 -1
  54. package/dist/route-results.js +2 -1
  55. package/dist/run-ending.d.ts +21 -0
  56. package/dist/run-ending.js +19 -0
  57. package/dist/scope.js +2 -2
  58. package/dist/server/render-results.js +3 -2
  59. package/dist/server/render-tasks.js +3 -2
  60. package/dist/setup-guide.d.ts +2 -2
  61. package/dist/slack-chat.js +2 -3
  62. package/dist/staged-runtimes.js +2 -1
  63. package/dist/store.d.ts +61 -2
  64. package/dist/store.js +127 -77
  65. package/dist/subagent-question.js +8 -6
  66. package/dist/surface.js +2 -2
  67. package/dist/task-moves.d.ts +90 -0
  68. package/dist/task-moves.js +68 -0
  69. package/dist/teams-chat.js +2 -2
  70. package/dist/telegram-flow.d.ts +1 -1
  71. package/dist/telegram-flow.js +1 -1
  72. package/dist/telegram-lead.d.ts +1 -2
  73. package/dist/telegram-lead.js +18 -23
  74. package/dist/telegram-progress.js +2 -1
  75. package/dist/telegram.d.ts +0 -1
  76. package/dist/telegram.js +6 -7
  77. package/dist/vanished-retry.js +3 -2
  78. package/dist/workspace-ui.js +2 -1
  79. package/dist/worktree.js +3 -2
  80. package/package.json +1 -1
@@ -18,7 +18,8 @@ import { callLabel, readOnlyCall, retryCall, retryingExec, writeWithRecheck } fr
18
18
  const sqlite = () => createRequire(import.meta.url)("node:sqlite");
19
19
  import { PROTECTION_MINIMUM_CHECKS, PROTECTION_REPORT_ONLY_CHECKS } from "./release.js";
20
20
  import { activeUpdateWork } from "./desktop-update-gate.js";
21
- import { BEFORE_SWAP, gateOwner } from "./release-gate.js";
21
+ import { BEFORE_SWAP, deployActionOf, stoppedWhereNothingChanged } from "./deploy-phase.js";
22
+ import { gateOwner } from "./release-gate.js";
22
23
  import { findCliLinks, switchCliLinks } from "./release-links.js";
23
24
  import { databasePath, finishReleaseOperation, requireFreshReleaseApproval, openStore, releaseAuthorityOfTask, releaseCarries, releaseOperations, renewReleaseLease, reserveRelease, runReleaseOperation, Store, recordReleaseStep, releaseIssuerGone, takeReleaseLease, } from "./store.js";
24
25
  import { acquireDeploymentLock } from "./deploy-lock.js";
@@ -62,8 +63,6 @@ const ownCli = () => {
62
63
  const bin = join(dirname(fileURLToPath(import.meta.url)), "bin.js");
63
64
  return existsSync(bin) ? [process.execPath, bin] : ["toolroll"];
64
65
  };
65
- /** Journal phases the deployment's finish accepts (scripts/deploy-phases.mjs finish): the new service runs, unproven. */
66
- const FINISHES_FORWARD = Object.freeze(["started", "healthy"]);
67
66
  const cleanEnv = (extra = {}) => {
68
67
  const env = { ...process.env, ...extra };
69
68
  delete env["NODE_OPTIONS"];
@@ -580,23 +579,40 @@ export function releaseAdapters(options) {
580
579
  const running = (options.processes ?? processes)().filter(one => one.command.includes("deploy-browser.mjs") && one.command.includes(stage));
581
580
  if (running.length > 0)
582
581
  return { ok: false, message: `the deployment is still running (process ${running.map(one => one.pid).join(", ")}); let it finish, then rerun` };
583
- // The new service started (deploy-rescue.ts FORWARD_ONLY): recovery refuses to roll it back, so the release
584
- // resumes it forward with the deployment's own finish (health, both consoles, leases, then the pause lifted).
582
+ // The new service was started (the phase table's "forward" rows, deploy-phase.ts): the release resumes it
583
+ // forward with the deployment's own finish (health, both consoles, leases, then the pause lifted).
585
584
  // Finishing deploys, so the candidate is proven first, as for a deploy.
586
- const phase = (() => { try {
585
+ const journalPhase = () => { try {
587
586
  return object(JSON.parse(readFileSync(join(stage, "deployment.json"), "utf8")))["phase"];
588
587
  }
589
588
  catch {
590
589
  return undefined;
591
- } })();
592
- if (FINISHES_FORWARD.includes(String(phase))) {
590
+ } };
591
+ const phase = journalPhase();
592
+ if (deployActionOf(phase) === "forward") {
593
593
  const proven = proveCandidate(runId);
594
- if (!proven.ok)
595
- return stopped(stage, proven.message);
596
- const finished = await deployScript(worktree, stage, runId, proven, ["--yes", "--phase", "finish"]);
597
- if (finished.code === 0)
594
+ let why;
595
+ if (proven.ok) {
596
+ const finished = await deployScript(worktree, stage, runId, proven, ["--yes", "--phase", "finish"]);
597
+ if (finished.code === 0)
598
+ return { ok: true };
599
+ why = `${tail(finished)} (the deployment journal is at ${String(phase)}; finishing it forward stopped; ${join(stage, "deployment.json")})`;
600
+ }
601
+ else
602
+ why = proven.message;
603
+ // Once the new service was recorded as started, a journal alone never rolls it back: the release stops here.
604
+ // Still at "starting" (killed while waiting for the new service; finish only looks, and it did not find it
605
+ // running): the deployment's own recovery puts the previous service back, and only when it proves the new
606
+ // one gone. That needs no approval of the candidate, exactly as below.
607
+ if (journalPhase() !== "starting")
608
+ return stopped(stage, why);
609
+ const recorded = recordedCommit(runId);
610
+ if (recorded === null)
611
+ return stopped(stage, why);
612
+ const back = await deployScript(worktree, stage, runId, recorded, ["--phase", "recover"]);
613
+ if (back.code === 0)
598
614
  return { ok: true };
599
- return stopped(stage, `${tail(finished)} (the deployment journal is at ${String(phase)}; finishing it forward stopped; ${join(stage, "deployment.json")})`);
615
+ return stopped(stage, `${why} The previous service was not put back either: ${tail(back)}`);
600
616
  }
601
617
  // Recovery needs no approval, but it runs only the recorded commit's own script, extracted by this runtime.
602
618
  const recorded = recordedCommit(runId);
@@ -642,7 +658,7 @@ export function releaseAdapters(options) {
642
658
  catch {
643
659
  return false;
644
660
  }
645
- return phase === "preparing" || phase === "released" || phase === "deployed";
661
+ return stoppedWhereNothingChanged(phase);
646
662
  },
647
663
  async cliLinks() { return findCliLinks(); },
648
664
  async switchLinks(links, bin, version) {
@@ -7,6 +7,15 @@ import { type ReleasePolicy, type ReleaseTerms } from "./contracts/release-terms
7
7
  import type { Store } from "./store.js";
8
8
  export type ReleaseState = "open" | "deploying" | "merge-reserved" | "merged" | "revoked" | "expired" | "superseded";
9
9
  export declare const LIVE_RELEASE_STATES: readonly ReleaseState[];
10
+ /**
11
+ * Whether an authority's approval has run out at `now`. Nothing writes the `expired` state: expiry is worked out from
12
+ * `expires_at` here, and every reader that asks whether an authority is still good asks through this (or
13
+ * releaseStateAt). The one SQL reader, approval-authority.ts releaseStandsSql, states the same rule in SQL.
14
+ */
15
+ export declare function releaseExpiredAt(a: Pick<ReleaseAuthority, "state" | "expiresAt">, now: Date): boolean;
16
+ /** An authority's state as read at `now`: a live one (open, deploying, merge-reserved) past its time reads as `expired`;
17
+ * an ended one keeps the state it ended in. */
18
+ export declare function releaseStateAt(a: Pick<ReleaseAuthority, "state" | "expiresAt">, now: Date): ReleaseState;
10
19
  /** One generation of a release a person approved: the single source of truth for it. */
11
20
  export type ReleaseAuthority = {
12
21
  id: number;
@@ -5,6 +5,19 @@
5
5
  */
6
6
  import { readReleasePolicy } from "./contracts/release-terms.js";
7
7
  export const LIVE_RELEASE_STATES = ["open", "deploying", "merge-reserved"];
8
+ /**
9
+ * Whether an authority's approval has run out at `now`. Nothing writes the `expired` state: expiry is worked out from
10
+ * `expires_at` here, and every reader that asks whether an authority is still good asks through this (or
11
+ * releaseStateAt). The one SQL reader, approval-authority.ts releaseStandsSql, states the same rule in SQL.
12
+ */
13
+ export function releaseExpiredAt(a, now) {
14
+ return a.state === "expired" || a.expiresAt <= now.toISOString();
15
+ }
16
+ /** An authority's state as read at `now`: a live one (open, deploying, merge-reserved) past its time reads as `expired`;
17
+ * an ended one keeps the state it ended in. */
18
+ export function releaseStateAt(a, now) {
19
+ return LIVE_RELEASE_STATES.includes(a.state) && releaseExpiredAt(a, now) ? "expired" : a.state;
20
+ }
8
21
  function readReleaseAuthority(row) {
9
22
  const text = (key) => row[key] == null ? null : String(row[key]);
10
23
  let policy = null;
@@ -26,7 +26,7 @@ import { releaseCheckStanding } from "./check-failure.js";
26
26
  import { CARRY_RULES, rulePath } from "./release-carry.js";
27
27
  import { AUTO_EVIDENCE_ACTION, AUTO_WITHDRAWN_ACTION, LIVE_RELEASE_STATES, RELEASE_APPROVAL_ACTION, releaseAuthorityOfTask, releaseAutoApproves, releaseApprovalSetting } from "./release-authority.js";
28
28
  import { autoReleaseApprovalOf, personApprovalOf } from "./scope.js";
29
- import { releaseOperations, revokeRelease } from "./store.js";
29
+ import { releaseOperations, revokeRelease, withdrawApproval } from "./store.js";
30
30
  import { RELEASE_CI_HELD_ACTION, RELEASE_CI_PASSED_ACTION, RELEASE_CI_RUNNING_WORDS, releaseCiHoldOf, releaseCiRunningOf } from "./release-ci-hold.js";
31
31
  import { verificationEvidence } from "./verification-evidence.js";
32
32
  import { nightlyDebtHold } from "./nightly-github.js";
@@ -87,8 +87,7 @@ export function setReleaseApproval(store, repo, auto, by, now) {
87
87
  * marked withdrawn, so no later reading — the setting turned on again included — lets it stand. */
88
88
  function unseal(store, taskId, now) {
89
89
  withdraw(store, taskId, now);
90
- store.handle.prepare(`UPDATE task_scope SET approved_at = NULL, approved_by = NULL, approved_digest = NULL, approved_profile_json = NULL,
91
- approved_route_json = NULL, approval_basis = NULL, mode_digest = NULL WHERE task_id = ? AND approval_basis IN ('auto-check', 'auto-green')`).run(taskId);
90
+ withdrawApproval(store, { where: "WHERE task_id = ? AND approval_basis IN ('auto-check', 'auto-green')", args: [taskId] }, { basisAfter: null, clearRoute: true, clearModeDigest: true });
92
91
  }
93
92
  /** Mark a task's automatic seal's evidence withdrawn for good (its seal row may stay while an issued merge reconciles). */
94
93
  function withdraw(store, taskId, now) {
@@ -18,9 +18,10 @@
18
18
  * Anything missing, damaged or contradicting refuses and leaves the pause as it is. Without --yes it only shows the
19
19
  * proof; with --yes it lifts that one pause (the owner is checked again inside the same write) and journals it.
20
20
  */
21
+ import { BEFORE_SWAP } from "./deploy-phase.js";
21
22
  import type { Database } from "./store.js";
22
- /** The journal phases a deployment passes before it stops the old service (deploy-phases.mjs). */
23
- export declare const BEFORE_SWAP: readonly string[];
23
+ /** The journal phases a deployment passes before it stops the old service (deploy-phase.ts, the one table). */
24
+ export { BEFORE_SWAP };
24
25
  export type GateReleaseWorld = {
25
26
  /** The live database file. */
26
27
  database: string;
@@ -81,4 +82,3 @@ export type GateReleaseOutcome = {
81
82
  };
82
83
  /** Prove, then (with `yes`) lift exactly that pause and journal the release in the deployment's own journal. */
83
84
  export declare function releaseGate(id: string, world: GateReleaseWorld, yes: boolean): GateReleaseOutcome;
84
- export {};
@@ -23,10 +23,11 @@ import { join } from "node:path";
23
23
  import { durableJson } from "./desktop-update.js";
24
24
  import { removeUpdateGate, updateGateOwned } from "./desktop-update-gate.js";
25
25
  import { acquireDeploymentLock } from "./deploy-lock.js";
26
- /** The journal phases a deployment passes before it stops the old service (deploy-phases.mjs). */
27
- export const BEFORE_SWAP = Object.freeze(["preparing", "admission-paused", "frozen", "backup-verified", "rehearsed"]);
26
+ import { BEFORE_SWAP, RELEASED } from "./deploy-phase.js";
27
+ /** The journal phases a deployment passes before it stops the old service (deploy-phase.ts, the one table). */
28
+ export { BEFORE_SWAP };
28
29
  /** Journal fields that only the swap and later phases write. */
29
- const SWAP_FIELDS = ["stoppingService", "codingBackupBeforeSwap", "migration", "newService", "deployedAt"];
30
+ const SWAP_FIELDS = ["stoppingService", "codingBackupBeforeSwap", "migration", "newService", "deployedAt", "launchd", "newServiceStop"];
30
31
  const GATE_ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
31
32
  const refuse = (reason, message) => ({ ok: false, reason, message: `${message} The update pause was left as it is.` });
32
33
  /** The owner id written into the live pause's triggers, when there is one pause and it names one id. */
@@ -121,7 +122,9 @@ export function proveBeforeSwap(id, world) {
121
122
  if (staged.length > 0)
122
123
  return refuse("after-swap", `Process ${staged.map(one => one.pid).join(", ")} runs from the staged runtime ${r.nextRuntime}.`);
123
124
  const supervisor = processes.find(one => one.command.includes(`${r.priorRuntime}/controller-service.js`));
124
- const worker = processes.find(one => one.command.includes(`${r.priorRuntime}/cli.js up --db ${world.database}`));
125
+ // The database as a whole argument: an `up` on a path that only begins with this database's is another service's.
126
+ const upHere = `${r.priorRuntime}/cli.js up --db ${world.database}`;
127
+ const worker = processes.find(one => one.command.split(upHere).slice(1).some(rest => rest === "" || /^\s/.test(rest)));
125
128
  if (supervisor === undefined || worker === undefined)
126
129
  return refuse("conflict", `The service is not running from ${r.priorRuntime}, so it can't be shown that the swap never began.`);
127
130
  proof.push(`The service runs from ${r.priorRuntime} (process ${supervisor.pid}); nothing runs from the staged runtime, and no deployment is running.`);
@@ -163,7 +166,7 @@ export function releaseGate(id, world, yes) {
163
166
  // The owner is checked again within removeUpdateGate's short write transaction.
164
167
  removeUpdateGate(db, id);
165
168
  durableJson(journalFile, {
166
- ...fresh.journal, phase: "released", updatedAt: world.now().toISOString(),
169
+ ...fresh.journal, phase: RELEASED, updatedAt: world.now().toISOString(),
167
170
  releasedBeforeSwap: { at: world.now().toISOString(), by: "toolroll release --release-gate", from: fresh.journal.phase, proof: fresh.proof },
168
171
  });
169
172
  return { ok: true, released: true, id, stage: fresh.stage, proof: fresh.proof };
@@ -1,3 +1,4 @@
1
+ import { deployActionOf, type DeployAction } from "./deploy-phase.js";
1
2
  import { type RescueWorld } from "./deploy-rescue.js";
2
3
  import { type GateReleaseWorld } from "./release-gate.js";
3
4
  import { type ReleaseJournal } from "./release.js";
@@ -29,9 +30,8 @@ export type SettleWorld = {
29
30
  rescue: RescueWorld;
30
31
  gate: GateReleaseWorld;
31
32
  };
32
- export type DeployAction = "none" | "lift-pause" | "forward" | "roll-back" | "refuse";
33
- /** The one action a deployment's journal calls for. */
34
- export declare function deployActionOf(phase: unknown): DeployAction;
33
+ /** The one action a deployment's journal calls for: a row of the one phase table (deploy-phase.ts). */
34
+ export { deployActionOf, type DeployAction };
35
35
  /** Settle what a stopped deployment left, saying what was done. A refusal stops the release before it does anything. */
36
36
  export declare function settleDeployment(world: SettleWorld, say: (line: string) => void): Promise<{
37
37
  ok: true;
@@ -11,8 +11,9 @@
11
11
  import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeSync } from "node:fs";
12
12
  import { hostname } from "node:os";
13
13
  import { dirname, join } from "node:path";
14
- import { FORWARD_ONLY, rescueDeployment } from "./deploy-rescue.js";
15
- import { BEFORE_SWAP, releaseGate } from "./release-gate.js";
14
+ import { deployActionOf } from "./deploy-phase.js";
15
+ import { rescueDeployment } from "./deploy-rescue.js";
16
+ import { releaseGate } from "./release-gate.js";
16
17
  import { journalFile, readJournal } from "./release.js";
17
18
  import { autoPublishHeld, PUBLISH_ALLOWED_ACTION } from "./release-auto.js";
18
19
  import { sameMachineHost } from "./machine-identity.js";
@@ -78,18 +79,8 @@ export async function takeReleaseRun(stateDir, branch, say, seams = {}) {
78
79
  await sleep(seams.pollMs ?? 5_000);
79
80
  }
80
81
  }
81
- /** The one action a deployment's journal calls for. */
82
- export function deployActionOf(phase) {
83
- if (typeof phase !== "string")
84
- return "refuse";
85
- if (phase === "released" || phase === "deployed")
86
- return "none";
87
- if (BEFORE_SWAP.includes(phase))
88
- return "lift-pause";
89
- if (FORWARD_ONLY.includes(phase))
90
- return "forward";
91
- return "roll-back";
92
- }
82
+ /** The one action a deployment's journal calls for: a row of the one phase table (deploy-phase.ts). */
83
+ export { deployActionOf };
93
84
  /** Settle what a stopped deployment left, saying what was done. A refusal stops the release before it does anything. */
94
85
  export async function settleDeployment(world, say) {
95
86
  const open = world.rescue.journals().filter(one => deployActionOf(one.journal?.["phase"]) !== "none");
@@ -99,8 +90,14 @@ export async function settleDeployment(world, say) {
99
90
  return { ok: false, message: `${open.length} deployments are unfinished: ${open.map(one => one.stage).join(", ")}. Nothing was changed.` };
100
91
  const { stage, journal } = open[0];
101
92
  const action = deployActionOf(journal?.["phase"]);
102
- if (action === "refuse")
103
- return { ok: false, message: `The deployment journal in ${stage} can't be read. Nothing was changed.` };
93
+ if (action === "refuse") {
94
+ // A word this version's table does not hold (a newer candidate's, or damage) is never acted on by itself: the
95
+ // deployment's own recovery knows its own phases, and the person starts it.
96
+ const phase = journal?.["phase"];
97
+ return typeof phase === "string"
98
+ ? { ok: false, message: `The deployment in ${stage} stopped at a step this version doesn't know (${/^[a-z-]{1,40}$/.test(phase) ? phase : "not a plain word"}). Nothing was changed. Its own recovery can decide: \`toolroll release --recover-deploy --yes\`.` }
99
+ : { ok: false, message: `The deployment journal in ${stage} can't be read. Nothing was changed.` };
100
+ }
104
101
  if (action === "forward") {
105
102
  say("Finishing the deployment that was under way.");
106
103
  return { ok: true };
@@ -6,6 +6,7 @@
6
6
  import { liveReleaseOf } from "./scope.js";
7
7
  import { nightlyReleaseLines } from "./nightly-github.js";
8
8
  import { releaseAuthorityOfTask, releaseCarries } from "./store.js";
9
+ import { releaseStateAt } from "./release-authority.js";
9
10
  export function releaseViewOf(store, taskId, now, obligations = () => []) {
10
11
  const found = releaseAuthorityOfTask(store, taskId);
11
12
  if (found === null)
@@ -18,7 +19,7 @@ export function releaseViewOf(store, taskId, now, obligations = () => []) {
18
19
  return {
19
20
  kind: found.carry === null ? "anchor" : "carried",
20
21
  authority: a.id, version: a.version, pr: a.pr, state: a.state,
21
- carrying: a.state === "open" && live.applies && live.ok, why: live.applies && !live.ok ? live.why : null,
22
+ carrying: releaseStateAt(a, now) === "open" && live.applies && live.ok, why: live.applies && !live.ok ? live.why : null,
22
23
  approver: anchor?.approvedBy ?? null, anchorTask: a.anchorTask, tipTask: a.tipTask, expiresAt: a.expiresAt,
23
24
  landed: releaseCarries(store, a.id).flatMap(carry => carry.commits.map(commit => ({ sha: commit.sha, task: carry.taskId, files: commit.files }))),
24
25
  obligations: obligations(a.id),
package/dist/release.js CHANGED
@@ -667,6 +667,17 @@ export async function runRelease(options, adapters) {
667
667
  return head !== null && head !== j.candidate.sha ? head : null;
668
668
  });
669
669
  };
670
+ /**
671
+ * Move the release back to an earlier step: write the fields the caller names (and only those), drop the named steps'
672
+ * intents, set the step, and save the journal. Every step-back goes through here.
673
+ */
674
+ const rewindTo = (step, fields, dropIntents = []) => {
675
+ Object.assign(j, fields);
676
+ for (const one of dropIntents)
677
+ delete j.intents[one];
678
+ j.step = step;
679
+ save();
680
+ };
670
681
  const restart = async (head, updated = false) => {
671
682
  // The step this commit was at ends here: the release goes on with the new commit (and, filed again, a new task).
672
683
  mark(j.step, "failed", `${j.branch} moved to ${head.slice(0, 7)}`);
@@ -678,16 +689,10 @@ export async function runRelease(options, adapters) {
678
689
  const carried = await rel.carry(j.authority.id, head, randomUUID(), epoch);
679
690
  if (carried.ok) {
680
691
  const view = await rel.authority(carried.taskId);
692
+ // The candidate is written before the gate is read, as it always was: a gate that fails leaves it moved.
681
693
  j.candidate = { sha: head, tree };
682
- j.task = { id: carried.taskId, scopeDigest: (await adapters.toolroll.gate(carried.taskId)).scopeDigest };
683
- j.check = null;
684
- j.completion = null;
685
- j.landed = view?.landed ?? j.landed ?? [];
686
- delete j.intents.check;
687
- delete j.intents.complete;
688
- j.step = "check";
689
- j.stopped = null;
690
- save();
694
+ const task = { id: carried.taskId, scopeDigest: (await adapters.toolroll.gate(carried.taskId)).scopeDigest };
695
+ rewindTo("check", { task, check: null, completion: null, landed: view?.landed ?? j.landed ?? [], stopped: null }, ["check", "complete"]);
691
696
  say(` carried the approval to ${head.slice(0, 7)} (task ${carried.taskId})`);
692
697
  return;
693
698
  }
@@ -695,30 +700,16 @@ export async function runRelease(options, adapters) {
695
700
  }
696
701
  else
697
702
  say(` ${j.branch} moved to ${head.slice(0, 7)}: filing it for approval`);
698
- j.candidate = { sha: head, tree };
699
- j.task = { id: gateTaskId(j.packageVersion, head), scopeDigest: null };
700
- j.approval = null;
701
- j.check = null;
702
- j.completion = null;
703
- j.authority = null;
704
- j.auto = null;
705
703
  epoch = null;
706
- delete j.intents.gate;
707
- delete j.intents.check;
708
- delete j.intents.complete;
709
- j.step = "gate";
710
- j.stopped = null;
711
- save();
704
+ rewindTo("gate", { candidate: { sha: head, tree }, task: { id: gateTaskId(j.packageVersion, head), scopeDigest: null },
705
+ approval: null, check: null, completion: null, authority: null, auto: null, stopped: null }, ["gate", "check", "complete"]);
712
706
  };
713
707
  /**
714
708
  * The automatic road ended without an approval: the plane takes its `auto-check` back (the task asks a person, its
715
709
  * passing check kept) and tells the owner; the release waits at approval for a person, as without the setting.
716
710
  */
717
711
  const autoRefused = async (why) => {
718
- j.auto = { state: "refused", why };
719
- j.check = null;
720
- j.step = "approval";
721
- save();
712
+ rewindTo("approval", { auto: { state: "refused", why }, check: null });
722
713
  await rel?.auto?.giveUp(j.task.id, why);
723
714
  throw new Stop("approval", `Not approved automatically: ${why}. A person approves task ${j.task.id} (\`toolroll task approve ${j.task.id} --digest ${j.task.scopeDigest}\`, or the console), then rerun \`${resume}\`.`);
724
715
  };
@@ -1053,10 +1044,7 @@ export async function runRelease(options, adapters) {
1053
1044
  }
1054
1045
  catch (error) {
1055
1046
  if (error instanceof Stop && error.reason === "ci-failed") {
1056
- j.auto = { state: "refused", why: error.message };
1057
- j.check = null;
1058
- j.step = "approval";
1059
- save();
1047
+ rewindTo("approval", { auto: { state: "refused", why: error.message }, check: null });
1060
1048
  await rel?.auto?.giveUp(j.task.id, error.message.replace(/\.$/, ""));
1061
1049
  }
1062
1050
  throw error;
@@ -169,6 +169,6 @@ export function refuseResultShots(state, store, part, content, app, now) {
169
169
  const title = chatTitle(store, content.image?.taskId ?? content.task ?? "");
170
170
  const line = { text: `Screenshots for ${title} weren't sent: the ${app} app isn't allowed to upload files here.`,
171
171
  ...(content.task === undefined ? {} : { task: content.task }), ...(content.run === undefined ? {} : { run: content.run }) };
172
- state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL,problem=NULL,file=NULL,uploaded=0,uncertain=0,created=? WHERE provider=:provider AND id=?").run(savedChatPart(line), now.toISOString(), part.id);
172
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL,problem=NULL,file=NULL,uploaded=0,uncertain=0,created=? WHERE provider=:provider AND id=? AND state<>'dropped'").run(savedChatPart(line), now.toISOString(), part.id);
173
173
  state.prepare("UPDATE chat_part SET state='dropped',problem='Uploads are not allowed here' WHERE provider=:provider AND event=? AND id<>? AND state='pending'").run(part.event, part.id);
174
174
  }
@@ -14,6 +14,7 @@ import { INELIGIBLE_REASONS, REASON_WORDS, selectionSchema, } from "./contracts/
14
14
  import { changedFilesOf, COMPLETION_ACTION } from "./result-completion.js";
15
15
  import { runAccounting } from "./store.js";
16
16
  import { INFRASTRUCTURE_TERMINALS, saveResultRecord } from "./route-store.js";
17
+ import { wasInterrupted } from "./run-ending.js";
17
18
  // The execution class and the cohort snapshot live with their writer (route-store.ts), which store.ts imports: kept
18
19
  // light so the store's module graph doesn't reach the evaluator's readers.
19
20
  export { cohortOf, EXEC_CLASS_OF_REASON, execClassOf } from "./route-store.js";
@@ -49,7 +50,7 @@ function classOfRun(run, kept) {
49
50
  return kept;
50
51
  if (run.terminalClass != null && INFRASTRUCTURE_TERMINALS.has(run.terminalClass))
51
52
  return "infrastructure";
52
- if ((run.outcome === "interrupted" || run.reason === "interrupted") && run.providerStartedAt === null)
53
+ if (wasInterrupted(run) && run.providerStartedAt === null)
53
54
  return "infrastructure";
54
55
  return "model";
55
56
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * How a run ended, asked one way.
3
+ *
4
+ * "Interrupted" is stored in two spellings. Every ending written today is the outcome `failed` with the reason
5
+ * `interrupted`. Rows written between schema 25 and 114 carry the outcome `interrupted` itself (a held session cut
6
+ * down mid-flight) and still read. A check that knows only one spelling treats the same event two ways, so every check
7
+ * asks here.
8
+ */
9
+ export declare function wasInterrupted(run: {
10
+ outcome: string | null;
11
+ reason?: string | null;
12
+ }): boolean;
13
+ /**
14
+ * The spelling written today, and only it. The roads that hand an interrupted attempt's draft to the next attempt
15
+ * (salvage in the checkout, the recovery grant, the check a run never reached) ask this one: they were built for the
16
+ * runs that write it, and reading the old spelling there would widen what they recover.
17
+ */
18
+ export declare function endedInterrupted(run: {
19
+ outcome: unknown;
20
+ reason?: unknown;
21
+ }): boolean;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * How a run ended, asked one way.
3
+ *
4
+ * "Interrupted" is stored in two spellings. Every ending written today is the outcome `failed` with the reason
5
+ * `interrupted`. Rows written between schema 25 and 114 carry the outcome `interrupted` itself (a held session cut
6
+ * down mid-flight) and still read. A check that knows only one spelling treats the same event two ways, so every check
7
+ * asks here.
8
+ */
9
+ export function wasInterrupted(run) {
10
+ return run.outcome === "interrupted" || (run.outcome === "failed" && run.reason === "interrupted");
11
+ }
12
+ /**
13
+ * The spelling written today, and only it. The roads that hand an interrupted attempt's draft to the next attempt
14
+ * (salvage in the checkout, the recovery grant, the check a run never reached) ask this one: they were built for the
15
+ * runs that write it, and reading the old spelling there would widen what they recover.
16
+ */
17
+ export function endedInterrupted(run) {
18
+ return run.outcome === "failed" && run.reason === "interrupted";
19
+ }
package/dist/scope.js CHANGED
@@ -1443,7 +1443,7 @@ import { permissionPlainWords } from "./chat-terms.js";
1443
1443
  import { limitRecognised } from "./exhaustion.js";
1444
1444
  import { SUBSCRIPTION_CAPABLE } from "./keys.js";
1445
1445
  import { releaseCheckPurposeDigest, releaseTermsDigest } from "./contracts/release-terms.js";
1446
- import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem } from "./release-authority.js";
1446
+ import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem, releaseStateAt } from "./release-authority.js";
1447
1447
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
1448
1448
  export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
1449
1449
  /** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
@@ -1558,7 +1558,7 @@ export function liveReleaseOf(store, taskId, action, now = new Date()) {
1558
1558
  if (a.state === "revoked")
1559
1559
  return refuse(`${a.endedBy ?? "an approver"} stopped carrying commits for this release`);
1560
1560
  // Expiry stops every live state (security review): an operation already reserved finishes only through reconciliation.
1561
- if (a.state === "expired" || ((a.state === "open" || a.state === "deploying" || a.state === "merge-reserved") && a.expiresAt <= now.toISOString()))
1561
+ if (releaseStateAt(a, now) === "expired")
1562
1562
  return refuse("the release approval expired");
1563
1563
  if (a.state === "merged")
1564
1564
  return refuse("the release was already merged");
@@ -2,6 +2,7 @@ import { assignmentPresentationOf, historicalAssessmentReason, shortenedMaterial
2
2
  import { assignmentActionHref, assignmentStatusOf, assignmentWithEvidence } from "../assignment-ui.js";
3
3
  import { assignmentChecksForRun, owedAcceptance } from "../assignment.js";
4
4
  import { releaseCoverageOf } from "../release-coverage.js";
5
+ import { wasInterrupted } from "../run-ending.js";
5
6
  import {} from "../browser-workspace.js";
6
7
  import { CHECK_LEVEL_WORDS, liveQuickCommand } from "../check-levels.js";
7
8
  import { LIMITS } from "../decision.js";
@@ -942,7 +943,7 @@ export function runOutcomeBadge(run, live) {
942
943
  if (!live && run.role === "planner" && run.reason === "plan-drafted") {
943
944
  return html `<span class="badge">Planned</span>`;
944
945
  }
945
- if (!live && run.reason === "interrupted")
946
+ if (!live && wasInterrupted(run))
946
947
  return html `<span class="badge">Interrupted</span>`;
947
948
  return live
948
949
  ? html `<span class="badge badge-running">Running</span>`
@@ -2080,7 +2081,7 @@ export function runOutcomeChip(run, live) {
2080
2081
  return chip("Running", "live");
2081
2082
  if (run.role === "planner" && run.reason === "plan-drafted")
2082
2083
  return chip("Planned", "done");
2083
- if (run.reason === "interrupted")
2084
+ if (wasInterrupted(run))
2084
2085
  return chip("Interrupted");
2085
2086
  const word = sentenceCase(run.outcome ?? "never finished");
2086
2087
  return chip(word, run.outcome === "built" || run.outcome === "no-change" ? "done" : run.outcome === "failed" || run.outcome === "refused" ? "failed" : "");
@@ -3,6 +3,7 @@ import { archivedWords } from "../tidy.js";
3
3
  import { btn } from "./kit.js";
4
4
  import { whenUtc } from "../when-html.js";
5
5
  import { networkWords } from "../egress.js";
6
+ import { wasInterrupted } from "../run-ending.js";
6
7
  import { chainTermsLines, liveChainFromJson, releaseAsksAgainWords, releaseTermsSentence } from "../scope.js";
7
8
  import { SUBSCRIPTION_CAPABLE } from "../keys.js";
8
9
  import { RELEASE_CI_RUNNING_WORDS } from "../release-ci-hold.js";
@@ -1862,7 +1863,7 @@ export function taskBodyParts(data) {
1862
1863
  const workerRow = liveRun !== undefined
1863
1864
  ? prop("builder", html `${liveRun.runner} · <a href="/r/${liveRun.id}">build #${liveRun.id}</a> running`)
1864
1865
  : data.runs[0] !== undefined
1865
- ? prop("last attempt", html `<a href="/r/${data.runs[0].id}">${runNoun(data.runs[0])} #${data.runs[0].id}</a> · ${data.runs[0].role === "planner" && data.runs[0].reason === "plan-drafted" ? "planned" : data.runs[0].reason === "interrupted" ? "interrupted" : data.runs[0].id === liveHistoryRunId ? "running" : data.runs[0].outcome ?? "never finished"} · ${data.runs[0].runner}`)
1866
+ ? prop("last attempt", html `<a href="/r/${data.runs[0].id}">${runNoun(data.runs[0])} #${data.runs[0].id}</a> · ${data.runs[0].role === "planner" && data.runs[0].reason === "plan-drafted" ? "planned" : wasInterrupted(data.runs[0]) ? "interrupted" : data.runs[0].id === liveHistoryRunId ? "running" : data.runs[0].outcome ?? "never finished"} · ${data.runs[0].runner}`)
1866
1867
  : "";
1867
1868
  const queueRow = data.position !== null && data.position !== undefined && task.state === "queued"
1868
1869
  ? prop("queue", html `${data.position.position} of ${data.position.total}${data.position.column === null ? " in the shared queue" : html ` in ${data.position.column}'s queue`} · <a href="/tasks?view=order">reorder</a>`)
@@ -1885,7 +1886,7 @@ export function taskBodyParts(data) {
1885
1886
  facts.push({ label: "Builder", parts: [`${liveRun.runner} · `, { label: `build #${liveRun.id}`, href: `/r/${liveRun.id}` }, " running"] });
1886
1887
  else if (lastRun !== undefined)
1887
1888
  facts.push({ label: "Last attempt", parts: [{ label: `${runNoun(lastRun)} #${lastRun.id}`, href: `/r/${lastRun.id}` },
1888
- ` · ${lastRun.role === "planner" && lastRun.reason === "plan-drafted" ? "planned" : lastRun.reason === "interrupted" ? "interrupted" : lastRun.id === liveHistoryRunId ? "running" : lastRun.outcome ?? "never finished"} · ${lastRun.runner}`] });
1889
+ ` · ${lastRun.role === "planner" && lastRun.reason === "plan-drafted" ? "planned" : wasInterrupted(lastRun) ? "interrupted" : lastRun.id === liveHistoryRunId ? "running" : lastRun.outcome ?? "never finished"} · ${lastRun.runner}`] });
1889
1890
  if (data.position !== null && data.position !== undefined && task.state === "queued") {
1890
1891
  facts.push({ label: "Queue", parts: [`${data.position.position} of ${data.position.total}${data.position.column === null ? " in the shared queue" : ` in ${data.position.column}'s queue`} · `, { label: "Reorder", href: "/tasks?view=order" }] });
1891
1892
  }
@@ -15,12 +15,12 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
- content?: never;
19
18
  ok: false;
20
19
  message: string;
21
20
  plan?: never;
22
21
  fingerprint?: never;
23
22
  installed?: never;
23
+ content?: never;
24
24
  } | {
25
25
  message?: never;
26
26
  ok: true;
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
- content?: never;
34
33
  ok: false;
35
34
  message: string;
36
35
  plan?: never;
37
36
  fingerprint?: never;
38
37
  installed?: never;
38
+ content?: never;
39
39
  } | import("./skills.js").InstallResult;
@@ -11,7 +11,7 @@ import { processChatEvent, planChatNotifications, channelAccess, planRoomMessage
11
11
  import { proposalPreview, proposalLink, proposalOutcomeText, armedCardText, armedYesLabel, } from "./chat-channel.js";
12
12
  import { LEAD_MESSAGE_MAX_CHARS } from "./lead.js";
13
13
  import { renderReply } from "./reply-shape.js";
14
- import { ChatDeliveryError, ChatState, chatHash, partContent } from "./chat-delivery-state.js";
14
+ import { ChatDeliveryError, ChatState, chatHash, chatRetryAt, partContent } from "./chat-delivery-state.js";
15
15
  import { readChatPart } from "./contracts/chat-content.js";
16
16
  import { readSlackBlockActions, readSlackButton, SLACK_LINK_ACTION } from "./contracts/slack-callback.js";
17
17
  import { object, slackId, slackTs, slackMember, SlackError, uploadSlackBytes, } from "./slack-api.js";
@@ -444,8 +444,7 @@ export async function deliverSlackPart(options) {
444
444
  ? error
445
445
  : new SlackError("Slack delivery is waiting to retry", 15_000, true);
446
446
  state.setProblem(identity.installation, options.owner, problem.message);
447
- const until = new Date(nowOf(options).getTime() +
448
- Math.max(problem.retryMs, [5000, 15000, 60000, 300000][Math.min(row.attempts, 3)])).toISOString();
447
+ const until = chatRetryAt(nowOf(options), problem.retryMs, row.attempts);
449
448
  state.partFailed(row.id, { problem: problem.message, until, uncertain: problem.uncertain });
450
449
  if (problem.code === "ratelimited")
451
450
  state.deferUntil(identity.installation, until);
@@ -12,6 +12,7 @@ import { lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSyn
12
12
  import { execFileSync } from "node:child_process";
13
13
  import { homedir } from "node:os";
14
14
  import { basename, dirname, join, resolve, sep } from "node:path";
15
+ import { isDeployed } from "./deploy-phase.js";
15
16
  import { quarantine, quarantineEntries, claimQuarantine, releaseQuarantine, restoreQuarantine, deleteQuarantineSync } from "./sweep-quarantine.js";
16
17
  import { processCensus } from "./run-closeout.js";
17
18
  const DAY_MS = 86_400_000;
@@ -84,7 +85,7 @@ function readStage(path, name, fallbackAt) {
84
85
  at = 0;
85
86
  }
86
87
  }
87
- const finished = deploy !== null ? deploy["phase"] === "deployed" : update !== null ? typeof update["finishedAt"] === "string" : false;
88
+ const finished = deploy !== null ? isDeployed(deploy["phase"]) : update !== null ? typeof update["finishedAt"] === "string" : false;
88
89
  const from = update?.["from"];
89
90
  const prior = typeof deploy?.["priorRuntime"] === "string" ? deploy["priorRuntime"] : typeof from?.dist === "string" ? from.dist : null;
90
91
  let keptAside = false;