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
@@ -21,6 +21,7 @@ import { canonicalRepos } from "./runner.js";
21
21
  import { fileTaskProposal } from "./proposal.js";
22
22
  import { describeScope } from "./scope.js";
23
23
  import { reportSummaryFor } from "./lead-tools.js";
24
+ import { claimedNowSql, markedRunningSql } from "./task-moves.js";
24
25
  import { taskWorkSummaryOf } from "./work-summary.js";
25
26
  import { assignmentOf, assignmentBrief } from "./assignment.js";
26
27
  /** Module-private: the ONLY maker, reachable only through the token
@@ -286,8 +287,7 @@ export function statusFor(store, who, now) {
286
287
  // finding 6): a live, unexpired, newest-generation claim.
287
288
  const running = one(`SELECT COUNT(DISTINCT claim.task_ref) AS n FROM claim
288
289
  JOIN task_ref ON task_ref.id = claim.task_ref AND task_ref.backend = 'built-in'
289
- WHERE task_ref.repo IN (${sql}) AND claim.released_at IS NULL AND claim.expires_at > ?
290
- AND claim.lease_generation = (SELECT MAX(newest.lease_generation) FROM claim AS newest WHERE newest.task_ref = claim.task_ref)`, [now.toISOString()]);
290
+ WHERE task_ref.repo IN (${sql}) AND ${claimedNowSql}`, [now.toISOString()]);
291
291
  const incidents = one(`SELECT COUNT(*) AS n FROM incident JOIN run ON run.id = incident.run
292
292
  JOIN task_ref ON task_ref.id = run.task_ref AND task_ref.backend = 'built-in'
293
293
  WHERE task_ref.repo IN (${sql}) AND incident.resolved_at IS NULL`);
@@ -298,7 +298,7 @@ export function statusFor(store, who, now) {
298
298
  const repos = store.handle
299
299
  .prepare(`SELECT task_ref.repo AS repo,
300
300
  SUM(CASE WHEN task.state = 'queued' THEN 1 ELSE 0 END) AS queued,
301
- SUM(CASE WHEN task.state = 'running' THEN 1 ELSE 0 END) AS running
301
+ SUM(CASE WHEN ${markedRunningSql} THEN 1 ELSE 0 END) AS running
302
302
  FROM task_ref JOIN task ON task.id = task_ref.external_id AND task_ref.backend = 'built-in'
303
303
  WHERE task_ref.repo IN (${sql}) GROUP BY task_ref.repo ORDER BY task_ref.repo`)
304
304
  .all(...args)
package/dist/demo.js CHANGED
@@ -1342,7 +1342,7 @@ export function createDemoLead(input) {
1342
1342
  return id;
1343
1343
  }
1344
1344
  };
1345
- /** Each stage's store facts, written once, in order. */
1345
+ /** Each stage's store facts, written once, in order. False when the stage can't start (its task was closed). */
1346
1346
  const enterStage = (exchange, stage, at) => {
1347
1347
  const plan = exchange.plan;
1348
1348
  const taskId = exchange.taskId;
@@ -1356,6 +1356,9 @@ export function createDemoLead(input) {
1356
1356
  }
1357
1357
  const route = store.approvedRouteOf(taskId);
1358
1358
  const claimed = acquire(store, ref, CREW, { now: at, token: crewSeen(at), ttlMs: 10 * 60_000 });
1359
+ // A task closed meanwhile (cancelled, say) is not built: no run starts and the story stops here.
1360
+ if (!claimed.ok && (claimed.reason === "closed" || claimed.reason === "no-task"))
1361
+ return false;
1359
1362
  const leaseId = claimed.ok ? claimed.claim.leaseId : `demo-lease-${taskId}`;
1360
1363
  const run = store.startRun({
1361
1364
  taskRef: ref,
@@ -1379,6 +1382,7 @@ export function createDemoLead(input) {
1379
1382
  }
1380
1383
  if (stage === 3)
1381
1384
  finish(exchange, at);
1385
+ return true;
1382
1386
  };
1383
1387
  const finish = (exchange, at) => {
1384
1388
  const plan = exchange.plan;
@@ -1454,7 +1458,8 @@ export function createDemoLead(input) {
1454
1458
  const current = exchange.stage === null ? -1 : DEMO_STAGES.indexOf(exchange.stage);
1455
1459
  for (let stage = current + 1; stage <= target; stage++) {
1456
1460
  const at = new Date(exchange.approvedAt + [0, 1, 3, 4][stage] * stepMs);
1457
- enterStage(exchange, stage, at);
1461
+ if (!enterStage(exchange, stage, at))
1462
+ break;
1458
1463
  if (stage < 3)
1459
1464
  exchange.stage = DEMO_STAGES[stage];
1460
1465
  else {
@@ -0,0 +1,21 @@
1
+ {
2
+ "phases": [
3
+ { "phase": "preparing", "action": "lift-pause", "flags": ["beforeSwap", "beforePause"] },
4
+ { "phase": "admission-paused", "action": "lift-pause", "flags": ["beforeSwap"] },
5
+ { "phase": "frozen", "action": "lift-pause", "flags": ["beforeSwap"] },
6
+ { "phase": "backup-verified", "action": "lift-pause", "flags": ["beforeSwap"] },
7
+ { "phase": "rehearsed", "action": "lift-pause", "flags": ["beforeSwap"] },
8
+ { "phase": "stopping", "action": "roll-back", "flags": [] },
9
+ { "phase": "stopped", "action": "roll-back", "flags": ["restartable"] },
10
+ { "phase": "migrating", "action": "roll-back", "flags": ["migrationStarted", "restartable"] },
11
+ { "phase": "migrated", "action": "roll-back", "flags": ["migrationStarted", "restartable"] },
12
+ { "phase": "starting", "action": "forward", "flags": [] },
13
+ { "phase": "started", "action": "forward", "flags": [] },
14
+ { "phase": "healthy", "action": "forward", "flags": [] },
15
+ { "phase": "start-failed", "action": "roll-back", "flags": ["migrationStarted", "restartable"] },
16
+ { "phase": "backup-restored", "action": "roll-back", "flags": ["restartable"] },
17
+ { "phase": "restored", "action": "roll-back", "flags": [] },
18
+ { "phase": "released", "action": "none", "flags": [] },
19
+ { "phase": "deployed", "action": "none", "flags": ["deployed"] }
20
+ ]
21
+ }
@@ -0,0 +1,37 @@
1
+ /** What `toolroll release` does with a deployment that stopped at a phase; "refuse" for a word that is no phase. */
2
+ export type DeployAction = "none" | "lift-pause" | "forward" | "roll-back" | "refuse";
3
+ type RowAction = Exclude<DeployAction, "refuse">;
4
+ type Flag = "beforeSwap" | "beforePause" | "migrationStarted" | "restartable" | "deployed";
5
+ type Row = {
6
+ readonly phase: string;
7
+ readonly action: RowAction;
8
+ readonly flags: readonly Flag[];
9
+ };
10
+ /** The rows of a parsed data file, or an Error naming what is wrong with it. Never a partial table. */
11
+ export declare function deployPhaseRows(data: unknown): readonly Row[];
12
+ /** Every phase, in journal order. */
13
+ export declare const DEPLOY_PHASES: readonly string[];
14
+ export declare const isDeployPhase: (phase: unknown) => phase is string;
15
+ /** The one action a journal's phase calls for; "refuse" for anything that is not a phase. */
16
+ export declare const deployActionOf: (phase: unknown) => DeployAction;
17
+ /** The word itself, or an Error: a journal is never written with a word the table does not hold. */
18
+ export declare function assertDeployPhase(phase: string): string;
19
+ /** The service was never stopped. */
20
+ export declare const BEFORE_SWAP: readonly string[];
21
+ /** Before the swap, with the pause in place. */
22
+ export declare const PAUSED_BEFORE_SWAP: readonly string[];
23
+ /** The candidate's store opened the live database, or the new service ran on it. */
24
+ export declare const MIGRATION_STARTED: readonly string[];
25
+ /** The old service is proved stopped: the previous definition may be started again. */
26
+ export declare const RESTARTABLE: readonly string[];
27
+ /** The new service may be running: never rolled back by a journal alone. */
28
+ export declare const FORWARD_ONLY: readonly string[];
29
+ /** Nothing is left to recover. Not the same as deployed: a released deployment never installed its runtime. */
30
+ export declare const NEEDS_NO_RECOVERY: readonly string[];
31
+ /** The deployment finished and its runtime is the installed one. */
32
+ export declare const isDeployed: (phase: unknown) => boolean;
33
+ /** Stopped where nothing was changed (before its pause), or with nothing left to do. */
34
+ export declare const stoppedWhereNothingChanged: (phase: unknown) => boolean;
35
+ /** The phase a lifted pause leaves behind (release-gate.ts). */
36
+ export declare const RELEASED: string;
37
+ export {};
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The phases of a browser deployment's journal, and what a stopped deployment at each one calls for.
3
+ *
4
+ * The rows are data: deploy-phase-data.json beside this file, the one authored list (the build copies it into dist,
5
+ * scripts/postbuild.mjs). The deploy scripts read the same file through scripts/deploy-phase-table.mjs, where each
6
+ * column is described. Both sides check its shape the same way; deploy-phase.test.ts holds them to each other and to
7
+ * its own hand-written expectations.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ const ACTIONS = ["none", "lift-pause", "forward", "roll-back"];
12
+ const FLAGS = ["beforeSwap", "beforePause", "migrationStarted", "restartable", "deployed"];
13
+ /** The rows of a parsed data file, or an Error naming what is wrong with it. Never a partial table. */
14
+ export function deployPhaseRows(data) {
15
+ const rows = data?.phases;
16
+ if (!Array.isArray(rows) || rows.length === 0)
17
+ throw new Error("The deployment phase table has no rows.");
18
+ const seen = new Set();
19
+ return Object.freeze(rows.map((row) => {
20
+ const { phase, action, flags } = (row ?? {});
21
+ if (typeof phase !== "string" || !/^[a-z]+(-[a-z]+)*$/.test(phase) || seen.has(phase))
22
+ throw new Error(`The deployment phase table has a bad or repeated phase: ${String(phase)}.`);
23
+ if (typeof action !== "string" || !ACTIONS.includes(action))
24
+ throw new Error(`The deployment phase ${phase} has no known action.`);
25
+ if (!Array.isArray(flags) || flags.some(flag => typeof flag !== "string" || !FLAGS.includes(flag)) || new Set(flags).size !== flags.length)
26
+ throw new Error(`The deployment phase ${phase} has an unknown or repeated flag.`);
27
+ seen.add(phase);
28
+ return Object.freeze({ phase, action: action, flags: Object.freeze([...flags]) });
29
+ }));
30
+ }
31
+ // By path, not by URL: a test environment that stands in for a browser replaces the global URL.
32
+ const ROWS = deployPhaseRows(JSON.parse(readFileSync(join(import.meta.dirname, "deploy-phase-data.json"), "utf8")));
33
+ const rowOf = (phase) => typeof phase === "string" ? ROWS.find(row => row.phase === phase) : undefined;
34
+ const withFlag = (flag) => Object.freeze(ROWS.filter(row => row.flags.includes(flag)).map(row => row.phase));
35
+ const withAction = (action) => Object.freeze(ROWS.filter(row => row.action === action).map(row => row.phase));
36
+ /** Every phase, in journal order. */
37
+ export const DEPLOY_PHASES = Object.freeze(ROWS.map(row => row.phase));
38
+ export const isDeployPhase = (phase) => rowOf(phase) !== undefined;
39
+ /** The one action a journal's phase calls for; "refuse" for anything that is not a phase. */
40
+ export const deployActionOf = (phase) => rowOf(phase)?.action ?? "refuse";
41
+ /** The word itself, or an Error: a journal is never written with a word the table does not hold. */
42
+ export function assertDeployPhase(phase) {
43
+ if (!isDeployPhase(phase))
44
+ throw new Error(`"${String(phase)}" is not a deployment phase; the journal was not written.`);
45
+ return phase;
46
+ }
47
+ /** The service was never stopped. */
48
+ export const BEFORE_SWAP = withFlag("beforeSwap");
49
+ /** Before the swap, with the pause in place. */
50
+ export const PAUSED_BEFORE_SWAP = Object.freeze(ROWS.filter(row => row.flags.includes("beforeSwap") && !row.flags.includes("beforePause")).map(row => row.phase));
51
+ /** The candidate's store opened the live database, or the new service ran on it. */
52
+ export const MIGRATION_STARTED = withFlag("migrationStarted");
53
+ /** The old service is proved stopped: the previous definition may be started again. */
54
+ export const RESTARTABLE = withFlag("restartable");
55
+ /** The new service may be running: never rolled back by a journal alone. */
56
+ export const FORWARD_ONLY = withAction("forward");
57
+ /** Nothing is left to recover. Not the same as deployed: a released deployment never installed its runtime. */
58
+ export const NEEDS_NO_RECOVERY = withAction("none");
59
+ /** The deployment finished and its runtime is the installed one. */
60
+ export const isDeployed = (phase) => rowOf(phase)?.flags.includes("deployed") === true;
61
+ /** Stopped where nothing was changed (before its pause), or with nothing left to do. */
62
+ export const stoppedWhereNothingChanged = (phase) => { const row = rowOf(phase); return row !== undefined && (row.flags.includes("beforePause") || row.action === "none"); };
63
+ /** The phase a lifted pause leaves behind (release-gate.ts). */
64
+ export const RELEASED = assertDeployPhase("released");
@@ -1,5 +1,6 @@
1
+ import { FORWARD_ONLY } from "./deploy-phase.js";
1
2
  /** Phases where the new service may be running: recovery leaves them to the release, which resumes forward. */
2
- export declare const FORWARD_ONLY: readonly string[];
3
+ export { FORWARD_ONLY };
3
4
  export type RescueWorld = {
4
5
  /** The live database. */
5
6
  database: string;
@@ -18,10 +18,9 @@
18
18
  */
19
19
  import { realpathSync } from "node:fs";
20
20
  import { join } from "node:path";
21
- import { BEFORE_SWAP } from "./release-gate.js";
21
+ import { BEFORE_SWAP, FORWARD_ONLY, MIGRATION_STARTED, NEEDS_NO_RECOVERY, RESTARTABLE } from "./deploy-phase.js";
22
22
  /** Phases where the new service may be running: recovery leaves them to the release, which resumes forward. */
23
- export const FORWARD_ONLY = Object.freeze(["starting", "started", "healthy"]);
24
- const FINISHED = Object.freeze(["released", "deployed"]);
23
+ export { FORWARD_ONLY };
25
24
  const real = (path) => { try {
26
25
  return realpathSync(path);
27
26
  }
@@ -39,8 +38,8 @@ export function rescuePlan(phase, journal) {
39
38
  if (phase === "stopping") {
40
39
  words.push("The old service must already be stopped. Recovery checks for remaining processes and refuses while any may still be running. Stop them through the service owner, then rerun recovery.");
41
40
  }
42
- if (["stopping", "stopped", "migrating", "migrated", "start-failed", "backup-restored"].includes(phase)) {
43
- if (["migrating", "migrated", "start-failed"].includes(phase))
41
+ if (phase === "stopping" || RESTARTABLE.includes(phase)) {
42
+ if (MIGRATION_STARTED.includes(phase))
44
43
  words.push("Puts the verified backup back first when the previous runtime can't read the migrated database.");
45
44
  words.push(`Starts the previous service (${prior}) again and waits for it to answer, past a stopped worker's heartbeat window; then lifts update pause ${id}.`);
46
45
  }
@@ -56,7 +55,7 @@ export async function rescueDeployment(stageArg, yes, world) {
56
55
  return { ok: false, reason: "usage", message: `${stageArg} is not a deployment stage of this installation (under ${join(world.stateDir, "staged-upgrades")}).` };
57
56
  }
58
57
  else {
59
- const open = all.filter(one => one.journal === null || !FINISHED.includes(String(one.journal["phase"])));
58
+ const open = all.filter(one => one.journal === null || !NEEDS_NO_RECOVERY.includes(String(one.journal["phase"])));
60
59
  if (open.length === 0)
61
60
  return { ok: true, acted: false, stage: null, phase: null, words: ["Nothing to recover: every deployment finished or was already put back."] };
62
61
  if (open.length > 1)
@@ -74,7 +73,7 @@ export async function rescueDeployment(stageArg, yes, world) {
74
73
  return { ok: false, reason: "conflict", message: `That deployment ran on ${String(journal["database"])}, not ${world.database}; nothing was changed.` };
75
74
  if (!Number.isSafeInteger(run) || run < 1)
76
75
  return { ok: false, reason: "damaged", message: `${join(stage, "deployment.json")} names no builder run, so its commit's recovery can't be found; nothing was changed.` };
77
- if (FINISHED.includes(phase))
76
+ if (NEEDS_NO_RECOVERY.includes(phase))
78
77
  return { ok: true, acted: false, stage, phase, words: [`Nothing to recover: that deployment is ${phase}.`] };
79
78
  if (FORWARD_ONLY.includes(phase)) {
80
79
  return { ok: false, reason: "forward", message: `That deployment is at ${phase}: the new service may be running, so nothing was stopped. Continue it forward with \`toolroll release <branch>\` (it resumes the deployment's finish).` };
@@ -7,7 +7,7 @@ import { cardWithin } from "./chat-card.js";
7
7
  import { chatFlowButtons } from "./chat-flow.js";
8
8
  import { refuseResultShots, resultShotsPruned, shotWaitsUntil } from "./result-shots.js";
9
9
  import { admitChatEvent } from "./chat-admission.js";
10
- import { ChatState, ChatDeliveryError, chatHash, partContent, } from "./chat-delivery-state.js";
10
+ import { ChatState, ChatDeliveryError, chatHash, chatRetryAt, partContent, } from "./chat-delivery-state.js";
11
11
  import { readChatPart } from "./contracts/chat-content.js";
12
12
  import { readDiscordButtonToken, readDiscordComponentInteraction } from "./contracts/discord-callback.js";
13
13
  import { processChatEvent, planChatNotifications, channelAccess, chatObject as object, planRoomMessages, heldForWatch, } from "./chat-delivery.js";
@@ -418,8 +418,7 @@ export async function deliverDiscordPart(options) {
418
418
  const problem = error instanceof ChatDeliveryError
419
419
  ? error
420
420
  : new DiscordError("Discord delivery is waiting to retry", 15_000, true);
421
- const until = new Date((options.clock?.() ?? new Date()).getTime() +
422
- Math.max(problem.retryMs, [5000, 15000, 60000, 300000][Math.min(row.attempts, 3)])).toISOString();
421
+ const until = chatRetryAt(options.clock?.() ?? new Date(), problem.retryMs, row.attempts);
423
422
  state.partFailed(row.id, { problem: problem.message, until, uncertain: problem.uncertain });
424
423
  state.setProblem(identity.installation, options.owner, problem.code === "ratelimited" ? "Discord asked us to wait. Saved replies will retry." : problem.message);
425
424
  if (problem.code === "ratelimited")
package/dist/dispatch.js CHANGED
@@ -19,6 +19,7 @@ import { failedAttemptSentence, latestFinishedAttempt } from "./needs-you.js";
19
19
  import { buildReviewOf } from "./review-switch.js";
20
20
  import { vanishedStopOf, vanishedStopSentence } from "./vanished-retry.js";
21
21
  import { routeChangeOf } from "./route-change.js";
22
+ import { wasInterrupted } from "./run-ending.js";
22
23
  import { BUILT_IN, refArchived, parseCapabilityKey } from "./store.js";
23
24
  import { shortWhen } from "./when-html.js";
24
25
  export const DEFAULT_MAX_OPEN_DECISIONS = 5;
@@ -157,7 +158,7 @@ function reviewViewOf(sourceRun, retry) {
157
158
  retriesRemaining: retry.retriesRemaining,
158
159
  latestRun: retry.latest?.runId ?? null,
159
160
  latestReason: retry.latest?.reason ?? null,
160
- interrupted: retry.latest !== null && (retry.latest.outcome === "interrupted" || retry.latest.reason === "interrupted"),
161
+ interrupted: retry.latest !== null && wasInterrupted(retry.latest),
161
162
  queued: retry.openRequest === null ? null : { requestedBy: retry.openRequest.requestedBy, origin: retry.openRequest.origin },
162
163
  };
163
164
  }
package/dist/dispose.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { STORAGE_HOLD_PREFIX } from "./needs-you.js";
2
2
  import { type FailureClass } from "./claim.js";
3
3
  import type { BuildResult } from "./builder.js";
4
- import type { Store } from "./store.js";
4
+ import type { ClosedState, Store } from "./store.js";
5
5
  import { type ProofVerdict } from "./proof.js";
6
6
  /**
7
7
  * Which road is disposing. 'tick' = the unattended loop: full task
@@ -59,6 +59,9 @@ export type Disposition = {
59
59
  disposition: string | null;
60
60
  strikes: number | null;
61
61
  sealed: boolean;
62
+ /** The state sealed when strikes ran out on a task a person had already closed (disposition "closed"): it is
63
+ * kept, with no incident, hold or page. Null for every other ending. */
64
+ closedState: ClosedState | null;
62
65
  }
63
66
  /** The standalone road's simple record: outcome written, nothing else. */
64
67
  | {
package/dist/dispose.js CHANGED
@@ -316,7 +316,7 @@ function disposeBuildOutcomeLocked(context, result) {
316
316
  }
317
317
  if (leaseId === undefined) {
318
318
  store.finishRun(runId, { outcome: "failed", reason: result.reason, now: clock() });
319
- return { kind: "failed", failureClass, disposition: null, strikes: null, sealed: false };
319
+ return { kind: "failed", failureClass, disposition: null, strikes: null, sealed: false, closedState: null };
320
320
  }
321
321
  const sealed = finalize(store, leaseId, {
322
322
  kind: "failure",
@@ -333,6 +333,7 @@ function disposeBuildOutcomeLocked(context, result) {
333
333
  disposition: sealed.ok ? sealed.disposition : null,
334
334
  strikes: sealed.ok && "strikes" in sealed ? sealed.strikes : null,
335
335
  sealed: sealed.ok,
336
+ closedState: sealed.ok && sealed.disposition === "closed" ? sealed.state : null,
336
337
  };
337
338
  }
338
339
  if (result.reason === "protected-storage") {
package/dist/fallback.js CHANGED
@@ -22,6 +22,7 @@ import { isProviderId, validateSpec } from "./provider.js";
22
22
  import { providerName } from "./provider-auth.js";
23
23
  import { PLAN_LOGIN_PROVABLE } from "./plan-login.js";
24
24
  import { canonicalChainJson, chainDigestOf, LIVE_CHAIN_VERSION, liveChainFromJson, machineActing, profileDigestOf } from "./scope.js";
25
+ import { moveTask } from "./task-moves.js";
25
26
  export const MAX_FALLBACK_ENTRIES = 3;
26
27
  const tablesSeen = new WeakSet();
27
28
  function hasTables(store) {
@@ -516,7 +517,7 @@ export function blockAfterSignedOut(store, runId, now) {
516
517
  store.handle.prepare("UPDATE run SET reason = 'fallback-signed-out' WHERE id = ? AND outcome = 'refused'").run(runId);
517
518
  const ref = store.refForId(run.taskRef);
518
519
  if (ref !== null)
519
- store.handle.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ? AND state IN ('queued','running')").run(now.toISOString(), ref.externalId);
520
+ moveTask(store.handle, ref.externalId, "giveUp", now);
520
521
  return true;
521
522
  });
522
523
  }
@@ -568,7 +569,7 @@ export function moveOnIfExhausted(store, runId, taskId, now) {
568
569
  store.handle.prepare("INSERT INTO fallback_move (walk, to_index, from_run, terminal_class, paid, moved_at) VALUES (?, ?, ?, ?, ?, ?)")
569
570
  .run(walk.id, run.chainIndex + 1, run.id, evidence.terminalClass, paid ? 1 : 0, now.toISOString());
570
571
  store.handle.prepare("UPDATE fallback_walk SET cursor = ? WHERE id = ?").run(run.chainIndex + 1, walk.id);
571
- store.handle.prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'").run(now.toISOString(), taskId);
572
+ moveTask(store.handle, taskId, "requeue", now);
572
573
  store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: store.refForId(run.taskRef)?.repo ?? null, taskId, runId: run.id, action: "work moved to fallback agent", outcome: paid ? "paid" : "plan", source: "work", detail: entryWords(to) });
573
574
  tell(store, run, taskId, "moved", to, now);
574
575
  store.bumpWake();
@@ -31,7 +31,7 @@ import { fileTaskProposal } from "./proposal.js";
31
31
  import { releaseCiHoldOf } from "./release-ci-hold.js";
32
32
  import { COMPLETION_ACTION, COMPLETION_BY_PERSON } from "./result-completion.js";
33
33
  import { deployGateScope, machineActing, propose, releaseCheckScope } from "./scope.js";
34
- import { BUILT_IN } from "./store.js";
34
+ import { BUILT_IN, withdrawApproval } from "./store.js";
35
35
  import { classifyTask, filingSizer, heuristicSizing } from "./task-sizing.js";
36
36
  import { issueImported } from "./task-source.js";
37
37
  import { titleWithin } from "./task-text.js";
@@ -295,7 +295,7 @@ export function expireFastStarts(store, now) {
295
295
  store.transact(() => {
296
296
  if (store.currentLiveLease(ref, now) !== null)
297
297
  return;
298
- store.handle.prepare(`UPDATE task_scope SET approved_at = NULL, approved_by = NULL, approved_digest = NULL, approved_profile_json = NULL, approved_route_json = NULL, approval_basis = NULL WHERE task_id = ?`).run(task);
298
+ withdrawApproval(store, { where: "WHERE task_id = ?", args: [task] }, { basisAfter: null, clearRoute: true, clearModeDigest: false });
299
299
  store.handle.prepare("UPDATE task_ref SET fast_start_until = NULL WHERE id = ?").run(ref);
300
300
  store.recordAction({ at: stamp, actor: "system", repo: row["repo"] === null ? null : String(row["repo"]), taskId: task, runId: null, action: "fast start expired", outcome: "seal cleared", source: "work", detail: `no build started within ${FAST_WINDOW_MS / 60_000} minutes` });
301
301
  cleared++;
@@ -19,6 +19,7 @@ import { createHash, createHmac, randomUUID } from "node:crypto";
19
19
  import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
20
20
  import { hostname } from "node:os";
21
21
  import { basename, join } from "node:path";
22
+ import { wasInterrupted } from "./run-ending.js";
22
23
  import { COMPLETION_ACTION } from "./result-completion.js";
23
24
  export const SIGNATURE_HEADER = "x-standing-orders-signature";
24
25
  const BATCH = 200;
@@ -164,7 +165,7 @@ export function spansFor(store, entries) {
164
165
  continue;
165
166
  const root = rootOf(taskId);
166
167
  const ref = store.refForId(run.taskRef);
167
- const failed = run.outcome === "failed" || run.outcome === "refused" || run.outcome === "interrupted";
168
+ const failed = run.outcome === "failed" || run.outcome === "refused" || wasInterrupted(run);
168
169
  spans.push({
169
170
  traceId: hex(`standing-orders/trace/${root}`, 32), spanId: hex(`standing-orders/run/${run.id}`, 16), parentSpanId: hex(`standing-orders/task/${root}`, 16),
170
171
  name: `${run.role} · ${run.provider}`, kind: 1, startTimeUnixNano: nanos(run.startedAt), endTimeUnixNano: nanos(run.finishedAt),
package/dist/operate.js CHANGED
@@ -123,7 +123,7 @@ import { homedir, hostname } from "node:os";
123
123
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
124
124
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
125
125
  import { dur } from "./design/kit.js";
126
- import { checkoutIdle, setCheckoutIdle, leadQuickAnswersOn, setLeadQuickAnswers, openStore, openStoreNoMigrate, initLocalMode, enrollOwnerPassword, installationCreatedBy, laptopPortOf, recordLaptopPort, localModeOf, localOwnerBinding, archiveTaskFamily, restoreTaskFamily, shipChecksForTask, elapsedSeconds, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
126
+ import { requeueEligibility, checkoutIdle, setCheckoutIdle, leadQuickAnswersOn, setLeadQuickAnswers, openStore, openStoreNoMigrate, initLocalMode, enrollOwnerPassword, installationCreatedBy, laptopPortOf, recordLaptopPort, localModeOf, localOwnerBinding, archiveTaskFamily, restoreTaskFamily, shipChecksForTask, elapsedSeconds, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
127
127
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
128
128
  import { claimInstance, clearInstance, openInstanceSocket } from "./console-instance.js";
129
129
  import { backgroundOff, backgroundOn, backgroundShow, laptopStatus, laptopStop, restartOwnBuilder } from "./laptop-background.js";
@@ -220,6 +220,7 @@ import { describePublicationGrant, observeChecks, publishPass, sweepMerges, } fr
220
220
  import { checkPublishing, publishingOf, savePublishing, followPullRequests, newestPullRequestOf } from "./pull-request-flow.js";
221
221
  import { starterOf, startersFor, switchOnStarter } from "./flow-starters.js";
222
222
  import { acquire, acquireIfReady, heartbeat, release, reap, currentClaim, DEFAULT_LEASE_MS, SYNC_MAX_AGE_MS, finalize, } from "./claim.js";
223
+ import { moveTask } from "./task-moves.js";
223
224
  import { disposeBuildOutcome, holdStaleApproval, regateTask } from "./dispose.js";
224
225
  import { proposeGrant, describeGrant, describeWithheld, permits, MUTATION_CLASSES, DEFAULT_MUTATIONS, } from "./grant.js";
225
226
  import { builtIn, guarded } from "./backend.js";
@@ -296,6 +297,7 @@ import { machineActing } from "./scope.js";
296
297
  import { reevaluateResultRecords } from "./route-results.js";
297
298
  import { holdRuleOff, routeRulesPass, taskEvidenceWords } from "./route-rules.js";
298
299
  import { ruleHoldOf } from "./route-store.js";
300
+ import { wasInterrupted } from "./run-ending.js";
299
301
  import { loginFileOf, migrateLegacyLogin, savedLoginFile, writableLoginFile } from "./login-secrets.js";
300
302
  /**
301
303
  * 0 done · 1 broke · 2 bad usage · 3 ran fine, the answer is no.
@@ -1672,12 +1674,9 @@ function claimCommand(positional, flags, context) {
1672
1674
  return fail(write, json, "claim", verdict.reason, verdict.message, EXIT.refused);
1673
1675
  }
1674
1676
  const ref = store.refFor(backend, id);
1675
- const result = acquire(store, ref.id, runner, {
1676
- now,
1677
- token,
1678
- ttlMs: ttl,
1679
- mutation: mutationFrom(flags, now),
1680
- });
1677
+ // The grant and the `running` it makes are one transaction (closed-task-claims.md §3.5): nobody can close the task
1678
+ // between them, and a refusal writes neither.
1679
+ const result = claimAndMarkRunning(store, backend, id, ref.id, runner, { now, token, ttlMs: ttl, mutation: mutationFrom(flags, now) });
1681
1680
  if (!result.ok) {
1682
1681
  // Two distinct refusals: somebody HOLDS it right now, or it is
1683
1682
  // RESERVED for a specific worker whoever asks (queue columns, v19).
@@ -1712,24 +1711,12 @@ function claimCommand(positional, flags, context) {
1712
1711
  return fail(write, json, "claim", "archived", `${id} is put away — restore it with \`toolroll task restore ${id}\` first`, EXIT.refused);
1713
1712
  if (result.reason === "draining")
1714
1713
  return fail(write, json, "claim", "draining", `Toolroll is restarting (draining): ${runner} takes no new work until it is back (by ${result.until})`, EXIT.refused, { until: result.until });
1714
+ if (result.reason === "closed")
1715
+ return fail(write, json, "claim", "closed", closedClaimWords(store, id, result.state, now), EXIT.refused, { state: result.state });
1716
+ if (result.reason === "no-task")
1717
+ return fail(write, json, "claim", "no-task", `no task \`${id}\``, EXIT.refused);
1715
1718
  return fail(write, json, "claim", result.reason, `held by ${result.by} until ${result.until}`, EXIT.refused, { holder: result.by, until: result.until });
1716
1719
  }
1717
- // Taking a task is what makes it running; leaving that to the caller would
1718
- // let a claimed task keep showing up as queued to anything reading state.
1719
- // On an idempotent REPLAY the transition runs only as the ONE narrow
1720
- // repair (external dispatch, finding 36): the stored claim is still the
1721
- // live lease and the task never left queued — anything else (cancelled,
1722
- // done, reopened elsewhere) mutates nothing.
1723
- if (result.replayed === true) {
1724
- const task = store.getTask(id);
1725
- const ref = store.lookupRef(id);
1726
- const stillMine = ref !== null && store.currentLiveLease(ref.id, now) === result.claim.leaseId;
1727
- if (task !== null && task.state === "queued" && stillMine)
1728
- store.setTaskState(id, "running", now);
1729
- }
1730
- else {
1731
- store.setTaskState(id, "running", now);
1732
- }
1733
1720
  return succeed(write, json, "claim", { lease: result.claim, reclaimed: result.reclaimed }, () => [
1734
1721
  `Claimed ${id} as ${runner}.`,
1735
1722
  ` lease ${result.claim.leaseId}`,
@@ -1751,20 +1738,18 @@ function leaseCommand(command, positional, flags, context) {
1751
1738
  const ttl = readTtl(flags);
1752
1739
  if (ttl === null)
1753
1740
  return fail(write, json, name, "usage", "--ttl takes whole seconds", EXIT.usage);
1754
- const result = command === "heartbeat" ? heartbeat(store, lease, now, ttl) : release(store, lease, now);
1741
+ // Releasing says the runner is finished with it, not that it succeeded, so a task left running is put back rather
1742
+ // than marked done, in the release's own transaction (releaseToQueue).
1743
+ const result = command === "heartbeat" ? heartbeat(store, lease, now, ttl) : releaseToQueue(store, lease, now);
1755
1744
  if (!result.ok) {
1745
+ if (result.reason === "closed") {
1746
+ return fail(write, json, name, "closed", `this lease ran out and its task is ${result.state} now, so it can't be renewed; stop rather than retry`, EXIT.refused, { state: result.state });
1747
+ }
1756
1748
  const message = result.reason === "fenced"
1757
1749
  ? "superseded — another runner holds this task now; stop rather than retry"
1758
1750
  : "no such lease";
1759
1751
  return fail(write, json, name, result.reason, message, EXIT.refused);
1760
1752
  }
1761
- if (command === "release") {
1762
- const task = store.getTask(String(refExternalId(store, result.claim.taskRef)));
1763
- // Releasing says the runner is finished with it, not that it succeeded, so
1764
- // a task left running is put back rather than marked done.
1765
- if (task !== null && task.state === "running")
1766
- store.setTaskState(task.id, "queued", now);
1767
- }
1768
1753
  return succeed(write, json, name, { lease: result.claim }, () => [
1769
1754
  command === "heartbeat"
1770
1755
  ? `Still yours until ${result.claim.expiresAt}.`
@@ -3512,7 +3497,7 @@ async function tickCommand(flags, context) {
3512
3497
  ? "auth-expired — requeued, sign-in needed"
3513
3498
  : disposition.disposition === "fallback"
3514
3499
  ? `out of plan — moved to the next approved agent`
3515
- : `${disposition.failureClass}${disposition.disposition === "backoff" ? ` — retry ${disposition.strikes}/3` : disposition.disposition === "stalled" ? " — stalled" : ""}`
3500
+ : `${disposition.failureClass}${disposition.disposition === "backoff" ? ` — retry ${disposition.strikes}/3` : disposition.disposition === "stalled" ? " — stalled" : disposition.closedState !== null ? ` — the task was already ${disposition.closedState}, so it stays ${disposition.closedState}` : ""}`
3516
3501
  : "fenced",
3517
3502
  ...(result.ok ? {} : { detail: result.message.slice(0, 200) }),
3518
3503
  worktree: leased.worktree.path,
@@ -9543,7 +9528,7 @@ function reviewStatusLines(review, sourceRun) {
9543
9528
  queued: "legacy request retained; the worker will close it without running a model review",
9544
9529
  running: `running — attempt ${review.live?.attempt ?? review.attempts.length} of ${review.cap}`,
9545
9530
  succeeded: `succeeded on attempt ${review.succeeded?.attempt ?? 1} of ${review.cap}`,
9546
- retryable: `${review.latest?.outcome === "interrupted" || review.latest?.reason === "interrupted" ? "interrupted" : "failed"} on attempt ${review.latest?.attempt ?? review.attempts.length} of ${review.cap} — inspect the saved result; model reviews no longer retry`,
9531
+ retryable: `${review.latest !== null && wasInterrupted(review.latest) ? "interrupted" : "failed"} on attempt ${review.latest?.attempt ?? review.attempts.length} of ${review.cap} — inspect the saved result; model reviews no longer retry`,
9547
9532
  exhausted: `exhausted — all ${review.cap} attempts ended without a review; nothing retries a fourth time`,
9548
9533
  };
9549
9534
  const first = review.state === "queued" && review.attempts.length === 0 ? "queued — waiting for a worker" : word[review.state];
@@ -12066,9 +12051,6 @@ function slug(title, now) {
12066
12051
  const stamp = now.toISOString().slice(11, 19).replace(/:/g, "");
12067
12052
  return words === "" ? `task-${stamp}` : `${words}-${stamp}`;
12068
12053
  }
12069
- function refExternalId(store, taskRef) {
12070
- return store.externalIdFor(taskRef);
12071
- }
12072
12054
  function describe(error) {
12073
12055
  return error instanceof Error ? error.message : String(error);
12074
12056
  }
@@ -14299,3 +14281,60 @@ function startServiceBeats(store) {
14299
14281
  void delivery.runNow();
14300
14282
  return beats;
14301
14283
  }
14284
+ /**
14285
+ * The CLI `claim`'s grant and its `running`, one transaction (docs/plans/closed-task-claims.md §3.5). A fresh grant of
14286
+ * a built-in task moves it to running through the narrow `claim` move; a replay of the same key repairs `running` only
14287
+ * when the task is still queued and the replayed lease is still the live one, as before. A task of another backend is
14288
+ * never written in the built-in table.
14289
+ */
14290
+ function claimAndMarkRunning(store, backend, id, taskRef, runner, options) {
14291
+ const { now } = options;
14292
+ return store.transact(() => {
14293
+ const result = acquire(store, taskRef, runner, options);
14294
+ if (!result.ok || backend !== BUILT_IN)
14295
+ return result;
14296
+ if (result.replayed === true) {
14297
+ const task = store.getTask(id);
14298
+ const stillMine = store.currentLiveLease(taskRef, now) === result.claim.leaseId;
14299
+ if (task !== null && task.state === "queued" && stillMine)
14300
+ store.setTaskState(id, "running", now);
14301
+ return result;
14302
+ }
14303
+ // The grant just read this row open in this transaction, so the move cannot miss; if it ever did, throwing rolls
14304
+ // the grant back with it.
14305
+ if (!moveTask(store.handle, id, "claim", now))
14306
+ throw new Error(`claim: ${id} could not be marked running`);
14307
+ store.bumpWake();
14308
+ return result;
14309
+ });
14310
+ }
14311
+ /**
14312
+ * The refusal words for a claim on a closed task: what state it is in, and only a next step that works for this task
14313
+ * (a requeue the store would accept, a cancelled task reopened through the state verb, otherwise the task itself).
14314
+ */
14315
+ function closedClaimWords(store, id, state, now) {
14316
+ const head = `${id} is ${state}, so it can't be claimed.`;
14317
+ if (state === "cancelled")
14318
+ return `${head} To run it again, reopen it: \`toolroll task state ${id} queued\``;
14319
+ if (requeueEligibility(store, id, now).ok)
14320
+ return `${head} To run it again: \`toolroll task requeue ${id}\``;
14321
+ return `${head} See its result: \`toolroll task show ${id}\``;
14322
+ }
14323
+ /**
14324
+ * `worker release`: hand the lease back and, when this release is the one that ended the claim, put a task left
14325
+ * running back in the queue, in one transaction, so no one can claim it in between (closed-task-claims.md §5). A
14326
+ * duplicate release changes nothing: by then another runner may hold the task.
14327
+ */
14328
+ function releaseToQueue(store, lease, now) {
14329
+ return store.transact(() => {
14330
+ const result = release(store, lease, now);
14331
+ if (!result.ok || result.duplicate === true)
14332
+ return result;
14333
+ const ref = store.refForId(result.claim.taskRef);
14334
+ if (ref === null || ref.backend !== BUILT_IN)
14335
+ return result;
14336
+ if (store.getTask(ref.externalId)?.state === "running")
14337
+ store.setTaskState(ref.externalId, "queued", now);
14338
+ return result;
14339
+ });
14340
+ }
@@ -30,6 +30,7 @@
30
30
  import { scanForSecrets } from "./evidence.js";
31
31
  import { scrubIntegrationText } from "./integrations.js";
32
32
  import { taskWaitSnapshot } from "./lead-status.js";
33
+ import { wasInterrupted } from "./run-ending.js";
33
34
  export const REVIEW_HOURS = 24;
34
35
  const EVIDENCE_LINES = 5;
35
36
  const WAITING_DAYS = 1;
@@ -73,7 +74,7 @@ export function runCause(run) {
73
74
  return "timeout";
74
75
  if (reason === "no-handoff")
75
76
  return "no-handoff";
76
- if (reason === "interrupted" || run.outcome === "interrupted")
77
+ if (wasInterrupted(run))
77
78
  return run.stopped ? null : "orphaned";
78
79
  if (PROVIDER_REASONS.has(reason) || run.terminalClass === "transient-throttle")
79
80
  return "provider";
@@ -22,18 +22,17 @@ 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
25
  history?: never;
27
26
  revision: number;
28
27
  stale: boolean;
29
28
  notice: string;
30
29
  title?: never;
30
+ content?: never;
31
31
  instructions?: never;
32
32
  references?: never;
33
33
  decisions?: never;
34
34
  waiting?: never;
35
35
  } | {
36
- content?: never;
37
36
  revision?: never;
38
37
  history?: never;
39
38
  stale?: never;
@@ -54,6 +53,7 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
54
53
  afterwards?: string;
55
54
  notice: string;
56
55
  title?: never;
56
+ content?: never;
57
57
  instructions?: never;
58
58
  references?: never;
59
59
  decisions?: never;
@@ -70,9 +70,9 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
70
70
  decisions?: never;
71
71
  waiting?: never;
72
72
  } | {
73
- content?: never;
74
73
  stale?: never;
75
74
  title?: never;
75
+ content?: never;
76
76
  revision: number;
77
77
  history: {
78
78
  revision: number;