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
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);
package/dist/store.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type WritesMode } from "./writes-mode.js";
3
3
  import { type CheckAccess } from "./check-access.js";
4
4
  import { type ProjectSandbox } from "./project-sandbox.js";
5
5
  import type { FencePolicy } from "./agent-fence.js";
6
+ import type { ChatMessageOutcome } from "./chat-message-states.js";
6
7
  import { type NetworkProfile, type ProjectNetwork, type RunNetworkRecord } from "./egress.js";
7
8
  import { type AcpLaunch } from "./acp-attest.js";
8
9
  import { type CheckStamp, type StampReading } from "./check-stamp.js";
@@ -1805,8 +1806,8 @@ export type TelegramConversation = {
1805
1806
  nextAttemptAt: string | null;
1806
1807
  session: number | null;
1807
1808
  turn: number | null;
1808
- /** A short outcome word: answered, replayed, refused:<reason>, failed:<reason>, revoked, … */
1809
- outcome: string | null;
1809
+ /** Its result word (chat-message-states.ts); a row written by an older version may hold a word no longer listed. */
1810
+ outcome: ChatMessageOutcome | null;
1810
1811
  replyMessageId: string | null;
1811
1812
  createdAt: string;
1812
1813
  startedAt: string | null;
@@ -8552,3 +8553,61 @@ export declare function setLeadTurnRoute(store: Store, turn: number, generation:
8552
8553
  * reply asked one, less than LEAD_ASK_TTL_MS ago. Conservative on purpose: the quick-answer router (§8) keeps such a
8553
8554
  * message on the main model. */
8554
8555
  export declare function leadAskAwaitsAnswer(store: Store, thread: number, now: Date): boolean;
8556
+ /** When an invite can still be used: not revoked, not consumed, not expired, and fewer than ten admitted attempts.
8557
+ * One bound parameter: the current time as an ISO string. Every read and write that asks "is this invite usable" uses
8558
+ * this fragment, so the join page, the attempt counter, the join terms and the open-invites list agree. */
8559
+ export declare const INVITE_USABLE_SQL = "revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ? AND attempts < 10";
8560
+ /** How long a confirm step stays open: the Yes and Cancel buttons shown after a first tap, in every chat app. */
8561
+ export declare const CONFIRM_TTL_MS: number;
8562
+ /**
8563
+ * Withdraw task approvals: the one statement every withdrawal runs. It always clears approved_at, approved_by,
8564
+ * approved_digest and approved_profile_json, and sets approval_basis to `basisAfter`. The callers differ in what else
8565
+ * they clear, so that stays theirs to say: `clearRoute` clears approved_route_json, `clearModeDigest` clears
8566
+ * mode_digest. `where` is the caller's own WHERE clause (starting with WHERE) with its bound values; it is always a
8567
+ * fixed string in our code, never built from input. The check and the write are one statement. Returns the rows changed.
8568
+ */
8569
+ export declare function withdrawApproval(store: Store, rows: {
8570
+ where: string;
8571
+ args: readonly (string | number | null)[];
8572
+ }, writes: {
8573
+ basisAfter: "password" | null;
8574
+ clearRoute: boolean;
8575
+ clearModeDigest: boolean;
8576
+ }): number;
8577
+ /**
8578
+ * Whether `requeueTask` would run this task again, read without writing anything: the same checks in the same order,
8579
+ * so the requeue itself and every sentence that offers it agree. Read inside the caller's transaction.
8580
+ */
8581
+ export declare function requeueEligibility(store: Store, taskId: string, now: Date): {
8582
+ ok: true;
8583
+ taskRef: number;
8584
+ incidents: number[];
8585
+ state: string;
8586
+ rejectedRun: number | null;
8587
+ } | {
8588
+ ok: false;
8589
+ reason: "unknown-task" | "not-stalled" | "claimed" | "accepted-result" | "published" | "archived";
8590
+ };
8591
+ /** The three closed states (docs/plans/closed-task-claims.md). */
8592
+ export type ClosedState = Extract<TaskState, "done" | "failed" | "cancelled">;
8593
+ export declare function isClosedState(state: string): state is ClosedState;
8594
+ /**
8595
+ * What this database knows about the task behind a reference, for a claim: a built-in task's row and state, a
8596
+ * built-in reference with no task row, or a task of another backend (its state lives in that tracker, not here).
8597
+ * Read inside the caller's transaction, so the answer holds until it commits.
8598
+ */
8599
+ export declare function taskStateForClaim(store: Store, taskRef: number): {
8600
+ kind: "built-in";
8601
+ state: TaskState;
8602
+ } | {
8603
+ kind: "missing";
8604
+ } | {
8605
+ kind: "outside";
8606
+ };
8607
+ /**
8608
+ * Check cleanup's proof (acp-check.ts settleChecks, claim.ts acquireForCheckCleanup): the task reference of check
8609
+ * `checkId` when that check belongs to `runner`, has finished (passed or failed) and is not yet settled, is held right
8610
+ * now by exactly this cleanup `attempt`, and is bound to the built-in task named from the check row's own id. Null
8611
+ * otherwise. Every fact comes from rows only code writes; read inside the caller's transaction.
8612
+ */
8613
+ export declare function checkCleanupTaskOf(store: Store, checkId: number, runner: string, attempt: string, now: Date): number | null;
package/dist/store.js CHANGED
@@ -129,8 +129,10 @@ import { configBase, envValue, namedPath } from "./names.js";
129
129
  import { PULL_REQUEST_SCHEMA } from "./pull-request-schema.js";
130
130
  import { chatTurnAmountOf, combineReportedModels, providerTurnsOf, reportedCostUsdOf, reportedModelOf, runAmountOf } from "./reported-usage.js";
131
131
  import { usageOf } from "./run-efficiency.js";
132
+ import { endedInterrupted, wasInterrupted } from "./run-ending.js";
132
133
  import { YOUIYOUX_FILED_VIA } from "./contracts/youiyoux-request.js";
133
134
  import { cardShowsScope, cardTermsDigest, shownTermsDigest } from "./chat-terms.js";
135
+ import { markedRunningSql, moveTask } from "./task-moves.js";
134
136
  // PR #263 follow-up: the built-ins the Store money seal uses, captured as this module is evaluated, so code that later
135
137
  // replaces `Object.defineProperty` and the like can't fool the seal or the lock (lockStoreMoneyMethods refuses when
136
138
  // they've been replaced). The four sealed method references are recorded once, at the seal, in SEALED_MONEY_METHODS.
@@ -6768,10 +6770,7 @@ export class Store {
6768
6770
  return { ok: true };
6769
6771
  }
6770
6772
  const prior = this.db.prepare("SELECT state FROM task WHERE id = ?").get(id);
6771
- const { changes } = this.db
6772
- .prepare("UPDATE task SET state = ?, updated_at = ? WHERE id = ?")
6773
- .run(state, now.toISOString(), id);
6774
- if (Number(changes) === 0)
6773
+ if (!moveTask(this.db, id, "set", now, state))
6775
6774
  return { ok: false, reason: "unknown-task" };
6776
6775
  if (state === "done" && prior?.["state"] !== "done") {
6777
6776
  const done = this.lookupRef(id);
@@ -7588,9 +7587,7 @@ export class Store {
7588
7587
  .prepare(`UPDATE external_mirror SET dispatch_ok = 1, close_generation = NULL, reopened_by = ?, reopened_at = ?
7589
7588
  WHERE local_task_id = ?`)
7590
7589
  .run(by, now.toISOString(), localTaskId);
7591
- this.db
7592
- .prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ?")
7593
- .run(now.toISOString(), localTaskId);
7590
+ moveTask(this.db, localTaskId, "reopen", now);
7594
7591
  this.noteLifecycle({ taskRef, kind: "task-queued", identity: `t${taskRef}`, subject: "Back in the queue", body: "The tracker item is open again; another attempt can start.", link: chatControlHref("task", localTaskId) }, now);
7595
7592
  this.bumpWake();
7596
7593
  return { ok: true };
@@ -8514,10 +8511,7 @@ export class Store {
8514
8511
  WHERE tr.backend = 'built-in' AND tr.external_id = task_scope.task_id
8515
8512
  AND claim.released_at IS NULL AND claim.expires_at > ?)`;
8516
8513
  const demoted = this.db.prepare(`SELECT task_id FROM task_scope ${demotable}`).all(digest, now.toISOString()).map(row => String(row["task_id"]));
8517
- this.db
8518
- .prepare(`UPDATE task_scope SET approved_at = NULL, approved_by = NULL, approved_digest = NULL,
8519
- approved_profile_json = NULL, approval_basis = 'password', mode_digest = NULL ${demotable}`)
8520
- .run(digest, now.toISOString());
8514
+ withdrawApproval(this, { where: demotable, args: [digest, now.toISOString()] }, { basisAfter: "password", clearRoute: false, clearModeDigest: true });
8521
8515
  this.db.prepare("DELETE FROM plan_authorization WHERE basis = 'grant' AND mode_digest = ?").run(digest);
8522
8516
  for (const taskId of demoted) {
8523
8517
  const ref = this.lookupRef(taskId);
@@ -8562,12 +8556,7 @@ export class Store {
8562
8556
  .prepare(`SELECT task_id FROM task_scope ${demotable}`)
8563
8557
  .all(...demotableArgs)
8564
8558
  .map(row => String(row["task_id"]));
8565
- this.db
8566
- .prepare(`UPDATE task_scope
8567
- SET approved_at = NULL, approved_by = NULL, approved_digest = NULL,
8568
- approved_profile_json = NULL, approval_basis = 'password', mode_digest = NULL
8569
- ${demotable}`)
8570
- .run(...demotableArgs);
8559
+ withdrawApproval(this, { where: demotable, args: demotableArgs }, { basisAfter: "password", clearRoute: false, clearModeDigest: true });
8571
8560
  for (const taskId of demoted) {
8572
8561
  const ref = this.lookupRef(taskId);
8573
8562
  if (ref === null)
@@ -9263,7 +9252,7 @@ export class Store {
9263
9252
  * false: the disclosure boundary is one indistinguishable page (D6). */
9264
9253
  inviteIsLive(tokenValue, now) {
9265
9254
  const row = this.db
9266
- .prepare("SELECT 1 AS hit FROM invite WHERE token_hash = ? AND revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ? AND attempts < 10")
9255
+ .prepare(`SELECT 1 AS hit FROM invite WHERE token_hash = ? AND ${INVITE_USABLE_SQL}`)
9267
9256
  .get(createHash("sha256").update(tokenValue, "utf8").digest("hex"), now.toISOString());
9268
9257
  return row !== undefined;
9269
9258
  }
@@ -9276,7 +9265,7 @@ export class Store {
9276
9265
  admitInviteAttempt(tokenValue, now) {
9277
9266
  const row = this.db
9278
9267
  .prepare(`UPDATE invite SET attempts = attempts + 1
9279
- WHERE token_hash = ? AND revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ? AND attempts < 10
9268
+ WHERE token_hash = ? AND ${INVITE_USABLE_SQL}
9280
9269
  RETURNING role, minted_by`)
9281
9270
  .get(createHash("sha256").update(tokenValue, "utf8").digest("hex"), now.toISOString());
9282
9271
  return row === undefined
@@ -9297,6 +9286,8 @@ export class Store {
9297
9286
  // concurrent join must read as 'gone' — the indistinguishable dead
9298
9287
  // shape — never as a name collision that discloses the winner.
9299
9288
  const hash = createHash("sha256").update(args.tokenValue, "utf8").digest("hex");
9289
+ // Not INVITE_USABLE_SQL on purpose: admitInviteAttempt has already counted this submission, so the tenth
9290
+ // admitted attempt arrives here with attempts = 10 and must still be able to join.
9300
9291
  const live = this.db
9301
9292
  .prepare("SELECT 1 AS hit FROM invite WHERE token_hash = ? AND revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ?")
9302
9293
  .get(hash, now.toISOString());
@@ -9609,7 +9600,7 @@ export class Store {
9609
9600
  * never leave the store; an invite is named by its row id. */
9610
9601
  openInvites(now) {
9611
9602
  return this.db
9612
- .prepare("SELECT id, role, minted_by, minted_at, expires_at, attempts, projects_json FROM invite WHERE revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ? ORDER BY id")
9603
+ .prepare(`SELECT id, role, minted_by, minted_at, expires_at, attempts, projects_json FROM invite WHERE ${INVITE_USABLE_SQL} ORDER BY id`)
9613
9604
  .all(now.toISOString())
9614
9605
  .map(row => ({
9615
9606
  id: Number(row["id"]),
@@ -11930,53 +11921,11 @@ export class Store {
11930
11921
  * is still the one just approved, read inside this transaction ("moved" otherwise). */
11931
11922
  expect) {
11932
11923
  return this.transact(() => {
11933
- const ref = this.db
11934
- .prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?")
11935
- .get(BUILT_IN, taskId);
11936
- if (ref === undefined)
11937
- return { ok: false, reason: "unknown-task" };
11938
- const taskRef = Number(ref["id"]);
11939
- // A put-away task (v145) is restored before it runs again.
11940
- if (refArchived(this, taskRef))
11941
- return { ok: false, reason: "archived" };
11942
- const stamp = now.toISOString();
11943
- if (this.hasLiveClaim(taskRef, now))
11944
- return { ok: false, reason: "claimed" };
11945
- const incidents = this.db
11946
- .prepare(`SELECT incident.id FROM incident
11947
- JOIN run ON run.id = incident.run
11948
- WHERE run.task_ref = ? AND incident.resolved_at IS NULL`)
11949
- .all(taskRef)
11950
- .map(row => Number(row["id"]));
11951
- const state = String(this.db.prepare("SELECT state FROM task WHERE id = ?").get(taskId)?.["state"]);
11952
- // A finished task whose last result was rejected — a failed gate, a
11953
- // contradicting review, a refuted proof — runs again on the SAME
11954
- // filing: the branch keeps the earlier attempt, the sealed diff still
11955
- // spans from the original base, and a corrected candidate needs a
11956
- // re-scope, not a new task. An accepted or published result is final
11957
- // here; it is revised through a revision or its pull request.
11958
- let rejectedRun = null;
11959
- if (state === "done") {
11960
- // The guards hold whether or not an incident is open: a finished
11961
- // task's last result decides, and an accepted or published one is
11962
- // never reopened by resolving an incident beside it.
11963
- const last = this.db
11964
- .prepare("SELECT id FROM run WHERE task_ref = ? AND role = 'builder' AND finished_at IS NOT NULL ORDER BY id DESC LIMIT 1")
11965
- .get(taskRef);
11966
- if (last === undefined) {
11967
- if (incidents.length === 0)
11968
- return { ok: false, reason: "not-stalled" };
11969
- }
11970
- else {
11971
- rejectedRun = Number(last["id"]);
11972
- const final = this.finalResultReason(rejectedRun);
11973
- if (final !== null)
11974
- return { ok: false, reason: final };
11975
- }
11976
- }
11977
- else if (incidents.length === 0 && state !== "failed") {
11978
- return { ok: false, reason: "not-stalled" };
11979
- }
11924
+ // Who may run again is one read, shared with the claim refusal's words (requeueEligibility, end of file).
11925
+ const eligible = requeueEligibility(this, taskId, now);
11926
+ if (!eligible.ok)
11927
+ return eligible;
11928
+ const { taskRef, incidents, state, rejectedRun } = eligible;
11980
11929
  if (expect !== undefined) {
11981
11930
  const scope = this.db.prepare("SELECT digest, approved_digest FROM task_scope WHERE task_id = ?").get(taskId);
11982
11931
  if (state !== "done" || rejectedRun !== expect.run || scope === undefined || String(scope["digest"]) !== expect.scopeDigest ||
@@ -11990,9 +11939,7 @@ export class Store {
11990
11939
  // A person's Retry is a new person act: a fast start gets a new start window. A machine's never does.
11991
11940
  if (!machineActing())
11992
11941
  this.db.prepare("UPDATE task_ref SET fast_start_until = ? WHERE id = ? AND fast_start_until IS NOT NULL").run(new Date(now.getTime() + 30 * 60_000).toISOString(), taskRef);
11993
- this.db
11994
- .prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state IN ('failed','queued','done')")
11995
- .run(stamp, taskId);
11942
+ moveTask(this.db, taskId, "retry", now);
11996
11943
  // The stall's page is answered by the act itself.
11997
11944
  this.resolveEpisode(`stalled:${taskRef}`, now);
11998
11945
  this.noteLifecycle({
@@ -13511,7 +13458,7 @@ export class Store {
13511
13458
  if (taskId === null)
13512
13459
  return;
13513
13460
  const role = String(prior["role"]);
13514
- const stopped = result.outcome === "failed" && result.reason === "interrupted" && this.stopOf(id) !== null;
13461
+ const stopped = wasInterrupted(result) && this.stopOf(id) !== null;
13515
13462
  if (role === "builder") {
13516
13463
  if (stopped) {
13517
13464
  this.noteLifecycle({ taskRef, run: id, kind: "run-stopped", identity: `r${id}`, subject: `Build #${id} stopped`, body: "Its work is preserved. Resume this attempt from the task page when ready.", link: chatControlHref("recovery", taskId) }, now);
@@ -14079,7 +14026,7 @@ export class Store {
14079
14026
  return `run #${parent.id} left its draft in ${parent.worktree ?? "no worktree"} on ${parent.branch ?? "no branch"}, not ${run.worktree} on ${run.branch}`;
14080
14027
  // An attempt that stopped before its handoff (run 2085) left its work in
14081
14028
  // place for exactly this: the retry inherits it, like an interrupted one's.
14082
- const interrupted = parent.outcome === null || (parent.outcome === "failed" && (parent.reason === "interrupted" || parent.reason === "no-handoff"));
14029
+ const interrupted = parent.outcome === null || endedInterrupted(parent) || (parent.outcome === "failed" && parent.reason === "no-handoff");
14083
14030
  if (!interrupted)
14084
14031
  return `run #${parent.id} ended as ${parent.outcome}${parent.reason === null ? "" : ` (${parent.reason})`} — only an interrupted attempt's draft is recovered`;
14085
14032
  if (parent.outcome === null && this.liveClaimByLease(parent.leaseId, now) !== null)
@@ -18731,7 +18678,7 @@ export class Store {
18731
18678
  const q = this.db
18732
18679
  .prepare(`SELECT
18733
18680
  SUM(CASE WHEN task.state = 'queued' THEN 1 ELSE 0 END) AS queued,
18734
- SUM(CASE WHEN task.state = 'running' THEN 1 ELSE 0 END) AS running
18681
+ SUM(CASE WHEN ${markedRunningSql} THEN 1 ELSE 0 END) AS running
18735
18682
  FROM task JOIN task_ref ON task_ref.backend = 'built-in' AND task_ref.external_id = task.id
18736
18683
  WHERE task_ref.repo = ? AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)`)
18737
18684
  .get(repo);
@@ -21761,7 +21708,7 @@ export function serializeReleaseTerms(terms) {
21761
21708
  }
21762
21709
  // ---- one approval per release: the authority record (release-once.md, part 1) --------------------------------------
21763
21710
  export { LIVE_RELEASE_STATES, belongsToRelease, newestReleaseGeneration, outstandingReleaseOperations, releaseAuthority, releaseAuthorityOfTask, releaseCarries, releaseCarryTo, releaseOperation, releaseOperations, releaseSealProblem, } from "./release-authority.js";
21764
- import { LIVE_RELEASE_STATES, autoEvidenceOf, belongsToRelease, newestReleaseGeneration, outstandingReleaseOperations, releaseAuthority, releaseCarryTo, releaseOperation, releaseSealProblem, } from "./release-authority.js";
21711
+ import { LIVE_RELEASE_STATES, autoEvidenceOf, belongsToRelease, releaseExpiredAt, newestReleaseGeneration, outstandingReleaseOperations, releaseAuthority, releaseCarryTo, releaseOperation, releaseSealProblem, } from "./release-authority.js";
21765
21712
  /**
21766
21713
  * Open a release's authority for a person's seal of its terms (inside the seal's transaction): the next generation,
21767
21714
  * superseding the live one. False when it can't (releaseSealProblem), which refuses the seal.
@@ -21836,7 +21783,7 @@ export function reserveRelease(store, input) {
21836
21783
  return { ok: false, why: "This release still waits for GitHub CI. Resume it with `toolroll release`." };
21837
21784
  const at = input.now.toISOString();
21838
21785
  // Expiry stops every live state; an operation already reserved settles only through reconciliation.
21839
- if (a.expiresAt <= at)
21786
+ if (releaseExpiredAt(a, input.now))
21840
21787
  return { ok: false, why: "The release approval expired." };
21841
21788
  if (a.tipTask !== input.taskId || a.tipSha !== input.candidate)
21842
21789
  return { ok: false, why: `Task ${input.taskId} is not the release's current commit.` };
@@ -21882,7 +21829,7 @@ export function runReleaseOperation(store, id, epoch, now, issuer = currentRelea
21882
21829
  if (releaseCiRunningOf(store, op.taskId) !== null)
21883
21830
  return false;
21884
21831
  // Past the expiry nothing new starts; the reserved operation is settled by reconciliation instead.
21885
- if (a.expiresAt <= now.toISOString())
21832
+ if (releaseExpiredAt(a, now))
21886
21833
  return false;
21887
21834
  // The lease that reserved it still holds (a person's direct deploy: still no coordinator holding the release), and
21888
21835
  // the live-release predicate still accepts it, at this moment (review round 1).
@@ -22644,7 +22591,7 @@ export function restoreTaskFamily(store, taskId, by, now, guard) {
22644
22591
  */
22645
22592
  export function liveInviteTerms(store, tokenValue, now) {
22646
22593
  const row = store.handle
22647
- .prepare("SELECT role, minted_by, expires_at, projects_json FROM invite WHERE token_hash = ? AND revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ? AND attempts < 10")
22594
+ .prepare(`SELECT role, minted_by, expires_at, projects_json FROM invite WHERE token_hash = ? AND ${INVITE_USABLE_SQL}`)
22648
22595
  .get(createHash("sha256").update(tokenValue, "utf8").digest("hex"), now.toISOString());
22649
22596
  if (row === undefined)
22650
22597
  return null;
@@ -23249,3 +23196,106 @@ export function leadAskAwaitsAnswer(store, thread, now) {
23249
23196
  WHERE m.id = (SELECT MAX(id) FROM lead_message WHERE thread = ? AND role = 'assistant') AND t.state = 'answered'`).get(thread);
23250
23197
  return row !== undefined && Date.parse(String(row["at"])) + LEAD_ASK_TTL_MS > now.getTime();
23251
23198
  }
23199
+ /** When an invite can still be used: not revoked, not consumed, not expired, and fewer than ten admitted attempts.
23200
+ * One bound parameter: the current time as an ISO string. Every read and write that asks "is this invite usable" uses
23201
+ * this fragment, so the join page, the attempt counter, the join terms and the open-invites list agree. */
23202
+ export const INVITE_USABLE_SQL = "revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ? AND attempts < 10";
23203
+ /** How long a confirm step stays open: the Yes and Cancel buttons shown after a first tap, in every chat app. */
23204
+ export const CONFIRM_TTL_MS = 10 * 60_000;
23205
+ /**
23206
+ * Withdraw task approvals: the one statement every withdrawal runs. It always clears approved_at, approved_by,
23207
+ * approved_digest and approved_profile_json, and sets approval_basis to `basisAfter`. The callers differ in what else
23208
+ * they clear, so that stays theirs to say: `clearRoute` clears approved_route_json, `clearModeDigest` clears
23209
+ * mode_digest. `where` is the caller's own WHERE clause (starting with WHERE) with its bound values; it is always a
23210
+ * fixed string in our code, never built from input. The check and the write are one statement. Returns the rows changed.
23211
+ */
23212
+ export function withdrawApproval(store, rows, writes) {
23213
+ const set = ["approved_at = NULL", "approved_by = NULL", "approved_digest = NULL", "approved_profile_json = NULL",
23214
+ ...(writes.clearRoute ? ["approved_route_json = NULL"] : []), "approval_basis = ?", ...(writes.clearModeDigest ? ["mode_digest = NULL"] : [])];
23215
+ return Number(store.handle.prepare(`UPDATE task_scope SET ${set.join(", ")} ${rows.where}`).run(writes.basisAfter, ...rows.args).changes);
23216
+ }
23217
+ /**
23218
+ * Whether `requeueTask` would run this task again, read without writing anything: the same checks in the same order,
23219
+ * so the requeue itself and every sentence that offers it agree. Read inside the caller's transaction.
23220
+ */
23221
+ export function requeueEligibility(store, taskId, now) {
23222
+ const db = store.handle;
23223
+ const ref = db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, taskId);
23224
+ if (ref === undefined)
23225
+ return { ok: false, reason: "unknown-task" };
23226
+ const taskRef = Number(ref["id"]);
23227
+ // A put-away task (v145) is restored before it runs again.
23228
+ if (refArchived(store, taskRef))
23229
+ return { ok: false, reason: "archived" };
23230
+ if (store.hasLiveClaim(taskRef, now))
23231
+ return { ok: false, reason: "claimed" };
23232
+ const incidents = db
23233
+ .prepare(`SELECT incident.id FROM incident
23234
+ JOIN run ON run.id = incident.run
23235
+ WHERE run.task_ref = ? AND incident.resolved_at IS NULL`)
23236
+ .all(taskRef)
23237
+ .map(row => Number(row["id"]));
23238
+ const state = String(db.prepare("SELECT state FROM task WHERE id = ?").get(taskId)?.["state"]);
23239
+ // A finished task whose last result was rejected — a failed gate, a
23240
+ // contradicting review, a refuted proof — runs again on the SAME
23241
+ // filing: the branch keeps the earlier attempt, the sealed diff still
23242
+ // spans from the original base, and a corrected candidate needs a
23243
+ // re-scope, not a new task. An accepted or published result is final
23244
+ // here; it is revised through a revision or its pull request.
23245
+ let rejectedRun = null;
23246
+ if (state === "done") {
23247
+ // The guards hold whether or not an incident is open: a finished
23248
+ // task's last result decides, and an accepted or published one is
23249
+ // never reopened by resolving an incident beside it.
23250
+ const last = db
23251
+ .prepare("SELECT id FROM run WHERE task_ref = ? AND role = 'builder' AND finished_at IS NOT NULL ORDER BY id DESC LIMIT 1")
23252
+ .get(taskRef);
23253
+ if (last === undefined) {
23254
+ if (incidents.length === 0)
23255
+ return { ok: false, reason: "not-stalled" };
23256
+ }
23257
+ else {
23258
+ rejectedRun = Number(last["id"]);
23259
+ const final = store.finalResultReason(rejectedRun);
23260
+ if (final !== null)
23261
+ return { ok: false, reason: final };
23262
+ }
23263
+ }
23264
+ else if (incidents.length === 0 && state !== "failed") {
23265
+ return { ok: false, reason: "not-stalled" };
23266
+ }
23267
+ return { ok: true, taskRef, incidents, state, rejectedRun };
23268
+ }
23269
+ export function isClosedState(state) {
23270
+ return state === "done" || state === "failed" || state === "cancelled";
23271
+ }
23272
+ /**
23273
+ * What this database knows about the task behind a reference, for a claim: a built-in task's row and state, a
23274
+ * built-in reference with no task row, or a task of another backend (its state lives in that tracker, not here).
23275
+ * Read inside the caller's transaction, so the answer holds until it commits.
23276
+ */
23277
+ export function taskStateForClaim(store, taskRef) {
23278
+ const row = store.handle.prepare(`SELECT task_ref.backend AS backend, task.state AS state FROM task_ref
23279
+ LEFT JOIN task ON task.id = task_ref.external_id AND task_ref.backend = ?
23280
+ WHERE task_ref.id = ?`).get(BUILT_IN, taskRef);
23281
+ if (row === undefined)
23282
+ return { kind: "missing" };
23283
+ if (String(row["backend"]) !== BUILT_IN)
23284
+ return { kind: "outside" };
23285
+ if (row["state"] === null || row["state"] === undefined)
23286
+ return { kind: "missing" };
23287
+ return { kind: "built-in", state: String(row["state"]) };
23288
+ }
23289
+ /**
23290
+ * Check cleanup's proof (acp-check.ts settleChecks, claim.ts acquireForCheckCleanup): the task reference of check
23291
+ * `checkId` when that check belongs to `runner`, has finished (passed or failed) and is not yet settled, is held right
23292
+ * now by exactly this cleanup `attempt`, and is bound to the built-in task named from the check row's own id. Null
23293
+ * otherwise. Every fact comes from rows only code writes; read inside the caller's transaction.
23294
+ */
23295
+ export function checkCleanupTaskOf(store, checkId, runner, attempt, now) {
23296
+ const row = store.handle.prepare(`SELECT r.task_ref AS ref FROM acp_check_request r
23297
+ JOIN task_ref t ON t.id = r.task_ref AND t.backend = ? AND t.external_id = 'agent-check-' || r.id
23298
+ WHERE r.id = ? AND r.runner = ? AND r.state IN ('passed','failed') AND r.settled_at IS NULL
23299
+ AND r.settle_by = ? AND r.settle_until > ?`).get(BUILT_IN, checkId, runner, attempt, now.toISOString());
23300
+ return row === undefined ? null : Number(row["ref"]);
23301
+ }
@@ -11,7 +11,7 @@
11
11
  * the person it was asked of. Answered anywhere, every button retires.
12
12
  */
13
13
  import { randomBytes } from "node:crypto";
14
- import { partContent, savedChatPart } from "./chat-delivery-state.js";
14
+ import { partContent } from "./chat-delivery-state.js";
15
15
  import { answerSubagentQuestion } from "./subagent-work.js";
16
16
  import { labelOf } from "./subagent-admin.js";
17
17
  import { telegramButton } from "./contracts/telegram-callback.js";
@@ -97,12 +97,14 @@ export function chatQuestionButtons(state, part, now) {
97
97
  return state.prepare("SELECT token,choice FROM chat_question_action WHERE provider=:provider AND part=? AND consumed IS NULL AND expires>? ORDER BY rowid").all(part, now.toISOString())
98
98
  .map(one => ({ token: String(one.token), label: (labels.get(one.choice ?? "") ?? IN_WORDS).slice(0, 75), words: one.choice === null }));
99
99
  }
100
- /** Show the tapped notice again with the answer, and without its buttons. */
101
- function repaint(state, part, event, line) {
100
+ /** Show the tapped notice again with the answer, and without its buttons. A notice that was dropped isn't brought back:
101
+ * the line goes as a new message. */
102
+ function repaint(state, part, event, line, now) {
102
103
  const row = state.prepare("SELECT payload FROM chat_part WHERE provider=:provider AND id=?").get(part);
103
104
  const before = row === undefined ? { text: "" } : partContent(String(row["payload"]));
104
105
  const content = { text: `${before.text}\n\n${line}`.slice(0, 3400), edit: event.ts, ...(before.link === undefined ? {} : { link: before.link }), ...(before.channel === undefined ? {} : { channel: before.channel }) };
105
- state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE provider=:provider AND id=?").run(savedChatPart(content), part);
106
+ if (!state.repaint(part, content))
107
+ state.plan(event.id, [{ text: line }], now);
106
108
  }
107
109
  /** A tapped question button, inside the action's transaction; false when the token isn't one. */
108
110
  export function applyChatQuestionTap(options, event, binding, token, now) {
@@ -120,7 +122,7 @@ export function applyChatQuestionTap(options, event, binding, token, now) {
120
122
  const question = store.subagentQuestion(questionId);
121
123
  if (action["consumed"] !== null || String(action["expires"]) <= now.toISOString() || question === null || question.state !== "open") {
122
124
  retireEverywhere(store, questionId, now, state);
123
- repaint(state, part, event, "This was already answered.");
125
+ repaint(state, part, event, "This was already answered.", now);
124
126
  state.finish(event.id);
125
127
  return true;
126
128
  }
@@ -139,7 +141,7 @@ export function applyChatQuestionTap(options, event, binding, token, now) {
139
141
  }
140
142
  retireEverywhere(store, questionId, now, state);
141
143
  const label = question.options.find(one => one.id === action["choice"])?.label ?? String(action["choice"]);
142
- repaint(state, part, event, `✅ You answered: ${label}. ${answered.said}`);
144
+ repaint(state, part, event, `✅ You answered: ${label}. ${answered.said}`, now);
143
145
  state.finish(event.id);
144
146
  return true;
145
147
  }
package/dist/surface.js CHANGED
@@ -366,10 +366,10 @@ const GUIDE_ROWS = [
366
366
  { name: "runner", takesValue: true, meaning: "your registered worker name" },
367
367
  { name: "token", takesValue: true, meaning: "the worker's token" },
368
368
  { name: "ttl", takesValue: true, meaning: "lease seconds" }],
369
- notableReasons: ["held", "fenced", "unapproved", "reserved", "external", "claimed", "not-ready"] },
369
+ notableReasons: ["held", "fenced", "unapproved", "reserved", "external", "claimed", "not-ready", "closed"] },
370
370
  { invocation: "heartbeat", synopsis: "still working; extends the lease", audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
371
371
  positionals: [{ name: "lease", required: true, meaning: "the lease id" }],
372
- flags: [jsonFlag, dbFlag], notableReasons: ["not-leased", "fenced"] },
372
+ flags: [jsonFlag, dbFlag], notableReasons: ["not-leased", "fenced", "closed"] },
373
373
  { invocation: "worker release", synopsis: "done with it; fenced if superseded", audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
374
374
  positionals: [{ name: "lease", required: true, meaning: "the lease id" }],
375
375
  flags: [jsonFlag, dbFlag], notableReasons: ["not-leased", "fenced"] },