toolroll 0.9.99 → 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 (70) hide show
  1. package/CHANGELOG.md +11 -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/claim.d.ts +40 -2
  23. package/dist/claim.js +92 -19
  24. package/dist/contracts/chat-tables.js +3 -0
  25. package/dist/coordinator.js +3 -3
  26. package/dist/demo.js +7 -2
  27. package/dist/desktop-update.d.ts +1 -1
  28. package/dist/discord-chat.js +2 -3
  29. package/dist/dispatch.js +2 -1
  30. package/dist/dispose.d.ts +4 -1
  31. package/dist/dispose.js +2 -1
  32. package/dist/fallback.js +3 -2
  33. package/dist/fast-start.js +2 -2
  34. package/dist/monitoring.js +2 -1
  35. package/dist/operate.js +75 -36
  36. package/dist/plane-review.js +2 -1
  37. package/dist/project-knowledge.d.ts +3 -3
  38. package/dist/release-authority.d.ts +9 -0
  39. package/dist/release-authority.js +13 -0
  40. package/dist/release-auto.js +2 -3
  41. package/dist/release-gate.js +1 -1
  42. package/dist/release-view.js +2 -1
  43. package/dist/release.js +18 -30
  44. package/dist/result-shots.js +1 -1
  45. package/dist/route-results.js +2 -1
  46. package/dist/run-ending.d.ts +21 -0
  47. package/dist/run-ending.js +19 -0
  48. package/dist/scope.js +2 -2
  49. package/dist/server/render-results.js +3 -2
  50. package/dist/server/render-tasks.js +3 -2
  51. package/dist/setup-guide.d.ts +2 -2
  52. package/dist/slack-chat.js +2 -3
  53. package/dist/store.d.ts +61 -2
  54. package/dist/store.js +127 -77
  55. package/dist/subagent-question.js +8 -6
  56. package/dist/surface.js +2 -2
  57. package/dist/task-moves.d.ts +90 -0
  58. package/dist/task-moves.js +68 -0
  59. package/dist/teams-chat.js +2 -2
  60. package/dist/telegram-flow.d.ts +1 -1
  61. package/dist/telegram-flow.js +1 -1
  62. package/dist/telegram-lead.d.ts +1 -2
  63. package/dist/telegram-lead.js +18 -23
  64. package/dist/telegram-progress.js +2 -1
  65. package/dist/telegram.d.ts +0 -1
  66. package/dist/telegram.js +6 -7
  67. package/dist/vanished-retry.js +3 -2
  68. package/dist/workspace-ui.js +2 -1
  69. package/dist/worktree.js +3 -2
  70. package/package.json +1 -1
@@ -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;
@@ -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) {
@@ -27,7 +27,7 @@ import { BEFORE_SWAP, RELEASED } from "./deploy-phase.js";
27
27
  /** The journal phases a deployment passes before it stops the old service (deploy-phase.ts, the one table). */
28
28
  export { BEFORE_SWAP };
29
29
  /** Journal fields that only the swap and later phases write. */
30
- const SWAP_FIELDS = ["stoppingService", "codingBackupBeforeSwap", "migration", "newService", "deployedAt"];
30
+ const SWAP_FIELDS = ["stoppingService", "codingBackupBeforeSwap", "migration", "newService", "deployedAt", "launchd", "newServiceStop"];
31
31
  const GATE_ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
32
32
  const refuse = (reason, message) => ({ ok: false, reason, message: `${message} The update pause was left as it is.` });
33
33
  /** The owner id written into the live pause's triggers, when there is one pause and it names one id. */
@@ -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
+ }