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
@@ -0,0 +1,75 @@
1
+ /**
2
+ * An incoming chat message (a `chat_event` row of kind 'message'): what its states mean, the result words it is
3
+ * settled with, and the one question "which replies have not reached the person yet".
4
+ *
5
+ * States (contracts/chat-tables.ts): queued, running, done, failed, dropped.
6
+ *
7
+ * What `done` means differs by app, and nothing should read more into it than this:
8
+ * - Telegram: done after every planned part of the reply was sent (or dropped as moot, such as a card whose
9
+ * proposal was already answered elsewhere). It is written by `ChatMessages.finishMessage`, only by the claim
10
+ * holder and only from 'running'.
11
+ * - Slack, Discord and Teams: done as soon as the reply is planned, before any of it is sent. `ChatState.plan`
12
+ * writes the parts and then `ChatState.finish` marks the row done in the same transaction; the parts go out
13
+ * later, part by part, through `chat_part`.
14
+ * So `done` means "the message needs no more work from the lead", never "the person has the reply". To ask whether
15
+ * a reply has reached the person, use `unsent()` / `unsentPart()` below, which look at the parts themselves and
16
+ * give the same answer on every app.
17
+ *
18
+ * `failed` is used by Telegram only. `dropped` means the chat was disconnected (or an app gave the event up) while
19
+ * it waited; Telegram rows read a dropped row as failed. Dropped is final: `ChatState.finish` never writes over it,
20
+ * and `ChatState.plan` saves no reply for it.
21
+ */
22
+ import type { ResolvedLead } from "./chat-channel.js";
23
+ import type { LeadFailure, LeadRefusal } from "./lead.js";
24
+ /**
25
+ * Every fixed result word a message row can be written with. These are stored in `chat_event.result`, so each one
26
+ * must stay byte-identical: renaming one would change what older rows mean.
27
+ */
28
+ export declare const CHAT_MESSAGE_OUTCOMES: readonly ["answered", "replayed", "typed-start", "busy", "backlog", "replayed:running", "registry-unavailable", "registry-unavailable:admitted", "delivering", "answered:sending", "unpaired", "unsent:no-session", "replayed:missing", "typed-start:not-counted", "revoked"];
29
+ export type ChatMessageFixedOutcome = (typeof CHAT_MESSAGE_OUTCOMES)[number];
30
+ /** Words a message is queued under while it waits for another attempt (telegram-lead.ts `requeue`). */
31
+ export declare const CHAT_WAIT_OUTCOMES: readonly ["busy", "backlog", "replayed:running", "registry-unavailable", "registry-unavailable:admitted"];
32
+ export type ChatWaitOutcome = (typeof CHAT_WAIT_OUTCOMES)[number];
33
+ /** Waiting for the lead itself (it is answering, or earlier messages wait first): the two-hour bound applies. */
34
+ export declare const CHAT_LEAD_WAITS: readonly ["busy", "backlog"];
35
+ export type ChatLeadWait = (typeof CHAT_LEAD_WAITS)[number];
36
+ /** Waiting on a turn that was already admitted: no age bound (the turn's own deadline ends it). */
37
+ export declare const CHAT_ADMITTED_WAITS: readonly ["replayed:running", "registry-unavailable:admitted"];
38
+ /** The words a row is marked done with (the reply was sent). */
39
+ export type ChatDoneOutcome = "answered" | "replayed" | "typed-start";
40
+ type RefusedReason = LeadRefusal | Extract<ResolvedLead, {
41
+ ok: false;
42
+ }>["reason"];
43
+ /**
44
+ * Every result word, including the forms that carry a detail after a prefix or a suffix. Build the detailed forms
45
+ * with `chatOutcome` below, never by joining strings at the call site.
46
+ */
47
+ export type ChatMessageOutcome = ChatMessageFixedOutcome | `${ChatWaitOutcome}:gave-up` | `refused:${RefusedReason}` | `failed:${LeadFailure}` | `replayed:${string}` | `unsent:gave-up:${string}` | `unsent:${string}`;
48
+ /** Builders for the result words that carry a detail. The stored text is exactly what the old string joins wrote. */
49
+ export declare const chatOutcome: {
50
+ /** A wait that ran past its age bound. */
51
+ readonly gaveUp: <W extends ChatWaitOutcome>(wait: W) => `${W}:gave-up`;
52
+ /** The lead refused the turn, or no session could be resolved for it. */
53
+ readonly refused: <R extends RefusedReason>(reason: R) => `refused:${R}`;
54
+ /** The turn ran and failed. */
55
+ readonly failed: <R extends LeadFailure>(reason: R) => `failed:${R}`;
56
+ /** The recovered turn had ended without an answer: its failure reason as the turn row stores it ("failed" when none). */
57
+ readonly replayedFailed: (reason: string | null) => `replayed:${string}`;
58
+ /** The reply could not go out: the chat or its projects changed (the problem in words). */
59
+ readonly unsent: (problem: string) => `unsent:${string}`;
60
+ /** The reply was still unsent a day after it was planned (the last send error, or "unsent"). */
61
+ readonly unsentGaveUp: (error: string | null) => `unsent:gave-up:${string}`;
62
+ };
63
+ /** Is this word one of the waits for the lead itself (busy or backlog)? */
64
+ export declare function isLeadWait(outcome: string | null): outcome is ChatLeadWait;
65
+ /** Is this word a wait on a turn that was already admitted (no age bound)? */
66
+ export declare function isAdmittedWait(outcome: string | null): boolean;
67
+ /** SQL: this `chat_part` row (by its alias) has not reached the person: planned and neither sent nor dropped. */
68
+ export declare function unsentPart(part: string): string;
69
+ /**
70
+ * SQL: the `chat_event` row (by its alias or table name) has a reply part that has not reached the person yet.
71
+ * The one answer to "is anything of this reply unsent?" on every app; wrap it in NOT for "everything reached them
72
+ * or was dropped". `part` names the inner alias when the caller's query already uses the default.
73
+ */
74
+ export declare function unsent(event: string, part?: string): string;
75
+ export {};
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Every fixed result word a message row can be written with. These are stored in `chat_event.result`, so each one
3
+ * must stay byte-identical: renaming one would change what older rows mean.
4
+ */
5
+ export const CHAT_MESSAGE_OUTCOMES = [
6
+ // done: the reply was sent (Telegram)
7
+ "answered", // a fresh turn's reply
8
+ "replayed", // the reply of a turn found again after a restart, or sent on a later pass
9
+ "typed-start", // Toolroll's own reply to a typed yes on a Start card
10
+ // queued: waiting for the lead, with no age bound or the bounds in telegram-lead.ts
11
+ "busy", // the lead is answering something else
12
+ "backlog", // the person's own earlier messages wait first
13
+ "replayed:running", // a turn was already admitted for it and is still working
14
+ "registry-unavailable", // the project records could not be read
15
+ "registry-unavailable:admitted", // the same, but a turn already took the message
16
+ // queued: the reply is planned and only sending remains
17
+ "delivering",
18
+ "answered:sending", // a fresh turn's reply, handed to the bridge's cycle to send
19
+ // failed
20
+ "unpaired", // the chat's pairing changed before the turn
21
+ "unsent:no-session", // the session ended before the reply went out
22
+ "replayed:missing", // the turn its receipt names is gone
23
+ "typed-start:not-counted", // a typed yes whose card's turn is gone
24
+ // dropped: the chat was disconnected while the message waited (chat-core.ts)
25
+ "revoked",
26
+ ];
27
+ /** Words a message is queued under while it waits for another attempt (telegram-lead.ts `requeue`). */
28
+ export const CHAT_WAIT_OUTCOMES = ["busy", "backlog", "replayed:running", "registry-unavailable", "registry-unavailable:admitted"];
29
+ /** Waiting for the lead itself (it is answering, or earlier messages wait first): the two-hour bound applies. */
30
+ export const CHAT_LEAD_WAITS = ["busy", "backlog"];
31
+ /** Waiting on a turn that was already admitted: no age bound (the turn's own deadline ends it). */
32
+ export const CHAT_ADMITTED_WAITS = ["replayed:running", "registry-unavailable:admitted"];
33
+ /** Builders for the result words that carry a detail. The stored text is exactly what the old string joins wrote. */
34
+ export const chatOutcome = {
35
+ /** A wait that ran past its age bound. */
36
+ gaveUp: (wait) => `${wait}:gave-up`,
37
+ /** The lead refused the turn, or no session could be resolved for it. */
38
+ refused: (reason) => `refused:${reason}`,
39
+ /** The turn ran and failed. */
40
+ failed: (reason) => `failed:${reason}`,
41
+ /** The recovered turn had ended without an answer: its failure reason as the turn row stores it ("failed" when none). */
42
+ replayedFailed: (reason) => `replayed:${reason ?? "failed"}`,
43
+ /** The reply could not go out: the chat or its projects changed (the problem in words). */
44
+ unsent: (problem) => `unsent:${problem}`,
45
+ /** The reply was still unsent a day after it was planned (the last send error, or "unsent"). */
46
+ unsentGaveUp: (error) => `unsent:gave-up:${error ?? "unsent"}`,
47
+ };
48
+ /** Is this word one of the waits for the lead itself (busy or backlog)? */
49
+ export function isLeadWait(outcome) {
50
+ return CHAT_LEAD_WAITS.includes(outcome ?? "");
51
+ }
52
+ /** Is this word a wait on a turn that was already admitted (no age bound)? */
53
+ export function isAdmittedWait(outcome) {
54
+ return CHAT_ADMITTED_WAITS.includes(outcome ?? "");
55
+ }
56
+ const SQL_ALIAS = /^[a-z_][a-z0-9_]*$/;
57
+ function alias(name) {
58
+ if (!SQL_ALIAS.test(name))
59
+ throw new Error(`not a table alias: ${name}`);
60
+ return name;
61
+ }
62
+ /** SQL: this `chat_part` row (by its alias) has not reached the person: planned and neither sent nor dropped. */
63
+ export function unsentPart(part) {
64
+ return `${alias(part)}.state = 'pending'`;
65
+ }
66
+ /**
67
+ * SQL: the `chat_event` row (by its alias or table name) has a reply part that has not reached the person yet.
68
+ * The one answer to "is anything of this reply unsent?" on every app; wrap it in NOT for "everything reached them
69
+ * or was dropped". `part` names the inner alias when the caller's query already uses the default.
70
+ */
71
+ export function unsent(event, part = "unsent_part") {
72
+ const e = alias(event), p = alias(part);
73
+ return `EXISTS (SELECT 1 FROM chat_part ${p} WHERE ${p}.provider = ${e}.provider AND ${p}.event = ${e}.id AND ${unsentPart(p)})`;
74
+ }
@@ -1,4 +1,5 @@
1
1
  import { ChatCore } from "./chat-core.js";
2
+ import { type ChatMessageOutcome } from "./chat-message-states.js";
2
3
  import type { Notification, NotificationReceipt, Run, TelegramAction, TelegramBinding, TelegramConversation, TelegramConversationPart, TelegramConversationPartPlan, TelegramDelivery, TelegramDigest, TelegramFlowAction, TelegramFlowChoice, TelegramFlowPrompt, TelegramProposalAction } from "./store.js";
3
4
  /** v83: a notification for another person, settled for this destination without sending. */
4
5
  export declare const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
@@ -32,6 +33,8 @@ export declare const TELEGRAM_HOLD_REASONS: Readonly<{
32
33
  order: "Earlier task notification is still undelivered";
33
34
  disabled: "Telegram delivery is disabled";
34
35
  }>;
36
+ /** A note draft's states: `pending` and `armed` are live; the other three are final. */
37
+ export type NoteDraftState = "pending" | "armed" | "superseded" | "consumed" | "discarded";
35
38
  export declare class ChatMessages extends ChatCore {
36
39
  createDecisionAction(action: {
37
40
  token: string;
@@ -72,8 +75,9 @@ export declare class ChatMessages extends ChatCore {
72
75
  replyTo: string;
73
76
  note: string;
74
77
  }, now: Date, ttlMs?: number): boolean;
75
- /** Move a draft between states; the caller owns the transaction story. */
76
- setNoteDraftState(id: number, state: "pending" | "armed" | "superseded" | "consumed" | "discarded"): void;
78
+ /** Move a draft to `to`, only from one of the states in `from` (the check and the write are one statement). True when
79
+ * it moved; false when the draft was in none of them. The caller owns the transaction story. */
80
+ setNoteDraftState(id: number, from: readonly NoteDraftState[], to: NoteDraftState): boolean;
77
81
  createFlowActions(at: {
78
82
  binding: number;
79
83
  chatId: string;
@@ -202,14 +206,16 @@ export declare class ChatMessages extends ChatCore {
202
206
  * Settle a claimed row: done or failed with its outcome word, or back to
203
207
  * queued for a later attempt (a busy engine). Only the claim holder may;
204
208
  * a lapsed claim settles nothing, so a reclaimer's outcome stands.
209
+ * Telegram writes done here only once every part of the reply was sent
210
+ * or dropped; what done means on each app is in chat-message-states.ts.
205
211
  */
206
212
  finishMessage(id: number, owner: string, result: {
207
213
  state: "done" | "failed";
208
- outcome: string;
214
+ outcome: ChatMessageOutcome;
209
215
  replyMessageId?: string | null;
210
216
  } | {
211
217
  state: "queued";
212
- outcome: string;
218
+ outcome: ChatMessageOutcome;
213
219
  nextAttemptAt: string;
214
220
  }, now: Date): boolean;
215
221
  /**
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { neverHeld, watchedPing } from "./agent-board.js";
12
12
  import { ChatCore } from "./chat-core.js";
13
+ import { unsentPart, unsent } from "./chat-message-states.js";
13
14
  import { readChatMessageBody, readChatPart, savedChatEventBody, savedChatPart } from "./contracts/chat-content.js";
14
15
  import { isTelegramProgressNotification, proposalTaskOf, readNotification, readNotificationReceipt } from "./notification-rows.js";
15
16
  /** v83: a notification for another person, settled for this destination without sending. */
@@ -195,7 +196,7 @@ export class ChatMessages extends ChatCore {
195
196
  if (live !== undefined) {
196
197
  if (Number(live["event"]) >= draft.updateId)
197
198
  return false;
198
- chat.prepare("UPDATE chat_note_draft SET state = 'superseded' WHERE provider = :provider AND id = ?").run(live["id"]);
199
+ chat.setNoteDraftState(Number(live["id"]), ["pending", "armed"], "superseded");
199
200
  }
200
201
  chat
201
202
  .prepare(`INSERT INTO chat_note_draft (provider, id, binding, decision, event, message, reply_to, note, state, created, expires)
@@ -204,9 +205,11 @@ export class ChatMessages extends ChatCore {
204
205
  return true;
205
206
  });
206
207
  }
207
- /** Move a draft between states; the caller owns the transaction story. */
208
- setNoteDraftState(id, state) {
209
- this.prepare("UPDATE chat_note_draft SET state = ? WHERE provider = :provider AND id = ?").run(state, id);
208
+ /** Move a draft to `to`, only from one of the states in `from` (the check and the write are one statement). True when
209
+ * it moved; false when the draft was in none of them. The caller owns the transaction story. */
210
+ setNoteDraftState(id, from, to) {
211
+ return Number(this.prepare("UPDATE chat_note_draft SET state = ? WHERE provider = :provider AND id = ? AND state IN (SELECT value FROM json_each(?))")
212
+ .run(to, id, JSON.stringify(from)).changes) === 1;
210
213
  }
211
214
  createFlowActions(at, actions, now, days = 7) {
212
215
  const stamp = now.toISOString(), expires = new Date(now.getTime() + days * 86_400_000).toISOString();
@@ -454,6 +457,8 @@ export class ChatMessages extends ChatCore {
454
457
  * Settle a claimed row: done or failed with its outcome word, or back to
455
458
  * queued for a later attempt (a busy engine). Only the claim holder may;
456
459
  * a lapsed claim settles nothing, so a reclaimer's outcome stands.
460
+ * Telegram writes done here only once every part of the reply was sent
461
+ * or dropped; what done means on each app is in chat-message-states.ts.
457
462
  */
458
463
  finishMessage(id, owner, result, now) {
459
464
  const stamp = now.toISOString(), chat = this;
@@ -478,9 +483,9 @@ export class ChatMessages extends ChatCore {
478
483
  unsentReplies(botId, approver, since) {
479
484
  return this
480
485
  .prepare(`SELECT c.id, b.approver, MIN(p.created) AS since,
481
- (SELECT q.problem FROM chat_part q WHERE q.provider = c.provider AND q.event = c.id AND q.state = 'pending' ORDER BY q.ordinal LIMIT 1) AS error
486
+ (SELECT q.problem FROM chat_part q WHERE q.provider = c.provider AND q.event = c.id AND ${unsentPart("q")} ORDER BY q.ordinal LIMIT 1) AS error
482
487
  FROM chat_event c JOIN chat_binding b ON b.provider = c.provider AND b.id = c.binding
483
- JOIN chat_part p ON p.provider = c.provider AND p.event = c.id AND p.state = 'pending'
488
+ JOIN chat_part p ON p.provider = c.provider AND p.event = c.id AND ${unsentPart("p")}
484
489
  WHERE c.provider = :provider AND c.kind = 'message' AND c.state IN ('queued','running') AND (? IS NULL OR c.installation = ?) AND (? IS NULL OR b.approver = ?)
485
490
  GROUP BY c.id HAVING MIN(p.created) <= ? ORDER BY ${TELEGRAM_CONVERSATION_ORDER.replaceAll("e.", "c.")}`)
486
491
  .all(botId, botId, approver, approver, since.toISOString())
@@ -492,7 +497,7 @@ export class ChatMessages extends ChatCore {
492
497
  .prepare(`UPDATE chat_event SET next_at = ?
493
498
  WHERE provider = :provider AND kind = 'message' AND state = 'queued'
494
499
  AND (? IS NULL OR binding IN (SELECT id FROM chat_binding WHERE provider = :provider AND approver = ?))
495
- AND EXISTS (SELECT 1 FROM chat_part p WHERE p.provider = chat_event.provider AND p.event = chat_event.id AND p.state = 'pending')`)
500
+ AND ${unsent("chat_event")}`)
496
501
  .run(now.toISOString(), approver, approver);
497
502
  return Number(changes);
498
503
  }
@@ -27,6 +27,7 @@ import { INFRA_RERUNS_PER_CANDIDATE, INFRA_RERUNS_PER_INCIDENT } from "./release
27
27
  import { releaseCheckScope } from "./scope.js";
28
28
  import { isVerificationReceipt, verificationEvidence } from "./verification-evidence.js";
29
29
  import { checkAttemptOf } from "./store.js";
30
+ import { endedInterrupted } from "./run-ending.js";
30
31
  /** The plane's limits for confirmed infrastructure failures (one rerun per incident, two per candidate). */
31
32
  export { INFRA_RERUNS_PER_CANDIDATE, INFRA_RERUNS_PER_INCIDENT };
32
33
  /** The ledger action that records each rerun the release asked for, and why. */
@@ -208,7 +209,7 @@ export function setupReplayWords(first) {
208
209
  * got as far as its check keeps every rule below.
209
210
  */
210
211
  export function ranNoCheck(store, row) {
211
- if (row.outcome !== "failed" || (row.reason !== "interrupted" && row.reason !== "no-op") || row.head !== null || row.checkRow !== null)
212
+ if (!(endedInterrupted(row) || (row.outcome === "failed" && row.reason === "no-op")) || row.head !== null || row.checkRow !== null)
212
213
  return false;
213
214
  if (checkAttemptOf(store, row.id) !== null)
214
215
  return false;
@@ -36,7 +36,7 @@ export const CHECK_ARTIFACT_CAP = CHECK_REPORT_MAX_BYTES;
36
36
  /** The release check's harness, named: the orchestrator, its runners and helpers, Vitest's configuration and setup, and
37
37
  * the reporter. These never carry (release-carry.ts). The execution context digests far more (inHarnessDigest). */
38
38
  export const HARNESS_FILES = Object.freeze([
39
- "scripts/release-check.mjs", "scripts/release-check-ci.mjs", "scripts/suite-lifecycle.mjs", "scripts/provider-gate.mjs",
39
+ "scripts/release-check.mjs", "scripts/release-check-ci.mjs", "scripts/upgrade-rule.mjs", "scripts/suite-lifecycle.mjs", "scripts/provider-gate.mjs",
40
40
  "scripts/e2e-parallel.mjs", "scripts/e2e-kit.mjs", "scripts/upgrade-path.mjs", "scripts/check-results.mjs",
41
41
  "vitest.config.ts", "test/attacks/vitest.config.ts",
42
42
  "test/setup-state.ts", "test/fresh-store.ts", "test/temp-root.ts", "test/ensure-build.ts", "test/release-reporter.ts",
@@ -27,6 +27,7 @@
27
27
  import { createHash } from "node:crypto";
28
28
  import { existsSync, readFileSync, readdirSync } from "node:fs";
29
29
  import { basename, dirname, join } from "node:path";
30
+ import { isDeployed } from "./deploy-phase.js";
30
31
  import { taskBranches } from "./names.js";
31
32
  import { COMPLETION_ACTION } from "./result-completion.js";
32
33
  import { CLEANUP_CHOICES, IDLE_CHOICES, bytesWords, diskBytes, diskBytesAsync } from "./storage.js";
@@ -59,7 +60,7 @@ export function deployedRelease(stateDir) {
59
60
  catch {
60
61
  continue;
61
62
  }
62
- if (journal["phase"] !== "deployed")
63
+ if (!isDeployed(journal["phase"]))
63
64
  continue;
64
65
  const at = typeof journal["deployedAt"] === "string" ? journal["deployedAt"] : typeof journal["updatedAt"] === "string" ? journal["updatedAt"] : "";
65
66
  if (newest !== null && newest.at >= at)
package/dist/claim.d.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  * untestable, and a lease is exactly the kind of thing that has to be provable
28
28
  * rather than probable.
29
29
  */
30
- import { type Store, type Mutation, type TaskState } from "./store.js";
30
+ import { type Store, type Mutation, type TaskState, type ClosedState } from "./store.js";
31
31
  import type { ParsedDecision, Problem } from "./decision.js";
32
32
  import type { ParsedPlan } from "./plan.js";
33
33
  import { type PlannerSource } from "./planner-source.js";
@@ -115,6 +115,18 @@ export type AcquireResult = {
115
115
  ok: false;
116
116
  reason: "draining";
117
117
  until: string;
118
+ }
119
+ /** The task is done, failed or cancelled (docs/plans/closed-task-claims.md): nothing claims a closed task, except
120
+ * check cleanup's own door (acquireForCheckCleanup). */
121
+ | {
122
+ ok: false;
123
+ reason: "closed";
124
+ state: ClosedState;
125
+ }
126
+ /** A built-in reference with no task row behind it: nothing to claim. */
127
+ | {
128
+ ok: false;
129
+ reason: "no-task";
118
130
  };
119
131
  /** `fenced` means the lease was superseded; `unknown` means it never existed. */
120
132
  export type FenceResult = {
@@ -125,6 +137,12 @@ export type FenceResult = {
125
137
  ok: false;
126
138
  reason: "fenced" | "unknown" | "stopped";
127
139
  };
140
+ /** A heartbeat's answer: the fence, or `closed` when an expired claim's task was closed meanwhile (it is not renewed). */
141
+ export type HeartbeatResult = FenceResult | {
142
+ ok: false;
143
+ reason: "closed";
144
+ state: ClosedState;
145
+ };
128
146
  /**
129
147
  * Completion's OWN result (external dispatch, finding 33): `completed` is
130
148
  * today's world — the task is done/failed as asked; `disowned` means the
@@ -225,7 +243,7 @@ export declare function acquireIfReady(store: Store, taskRef: number, runner: st
225
243
  * has been superseded — which is the cheapest moment for it to find out, well
226
244
  * before it has finished work nobody will accept.
227
245
  */
228
- export declare function heartbeat(store: Store, leaseId: string, now: Date, ttlMs?: number): FenceResult;
246
+ export declare function heartbeat(store: Store, leaseId: string, now: Date, ttlMs?: number): HeartbeatResult;
229
247
  /**
230
248
  * Hand the task back.
231
249
  *
@@ -354,6 +372,13 @@ export type FailureDisposition = {
354
372
  ok: true;
355
373
  disposition: "fallback";
356
374
  provider: string;
375
+ }
376
+ /** Strikes used up on a task a person had already closed: its state is kept, and no incident, hold or page. */
377
+ | {
378
+ ok: true;
379
+ disposition: "closed";
380
+ strikes: number;
381
+ state: ClosedState;
357
382
  } | {
358
383
  ok: false;
359
384
  reason: "fenced" | "unknown" | "stopped";
@@ -557,6 +582,11 @@ export type ScoutFailureDisposition = {
557
582
  disposition: "stalled";
558
583
  incidentId: number;
559
584
  strikes: number;
585
+ } | {
586
+ ok: true;
587
+ disposition: "closed";
588
+ strikes: number;
589
+ state: ClosedState;
560
590
  } | {
561
591
  ok: true;
562
592
  disposition: "auth-expired";
@@ -587,3 +617,11 @@ export declare function currentClaim(store: Store, taskRef: number, now: Date):
587
617
  * that recovers and a system that appears to have lost the work.
588
618
  */
589
619
  export declare function reap(store: Store, now: Date): Claim[];
620
+ /**
621
+ * Check cleanup's claim (acp-check.ts settleChecks): the one road that may claim a closed task. Only the task bound to
622
+ * check `checkId`, when that check is this runner's, finished and unsettled, held right now by this cleanup `attempt`,
623
+ * and its task is the built-in one named from the check row's own id (store.ts checkCleanupTaskOf) — proved here, in
624
+ * the same transaction as the grant. Every other gate of the claim still applies. When the proof fails, the claim
625
+ * answers exactly as an ordinary one would: never wider.
626
+ */
627
+ export declare function acquireForCheckCleanup(store: Store, checkId: number, attempt: string, runner: string, options: AcquireOptions): AcquireResult;
package/dist/claim.js CHANGED
@@ -31,7 +31,7 @@ import { randomUUID } from "node:crypto";
31
31
  import { authenticate, drainOf } from "./runner.js";
32
32
  import { pauseForAuth } from "./provider-auth.js";
33
33
  import { DEFAULT_MAX_OPEN_DECISIONS, missingCapability, scopeApprovedForDispatch, taskReadinessBlocker, } from "./dispatch.js";
34
- import { BUILT_IN, refArchived, } from "./store.js";
34
+ import { BUILT_IN, checkCleanupTaskOf, isClosedState, refArchived, taskStateForClaim, } from "./store.js";
35
35
  import { digestOf } from "./scope.js";
36
36
  import { parseExecutionPlanDocument } from "./plan.js";
37
37
  import { sealUnchangedPlanUnderMode } from "./plan-auto.js";
@@ -39,6 +39,7 @@ import { routeFromJson } from "./phase-routing.js";
39
39
  import { contractChangesOf, decodePlannerSource, encodePlannerSource, canonicalContractJson, PLANNER_SOURCE_LIMITS, describeContractChanges, encodePlanContractRecord, plannerContractOf, plannerSourceDigest, } from "./planner-source.js";
40
40
  import { storeEvidence, readVerifiedArtifact, EVIDENCE_CAPS } from "./evidence.js";
41
41
  import { moveOnIfExhausted } from "./fallback.js";
42
+ import { moveTask } from "./task-moves.js";
42
43
  /** How stale a mirror's last complete sync may be before admission refuses. */
43
44
  export const SYNC_MAX_AGE_MS = 15 * 60_000;
44
45
  /**
@@ -60,7 +61,7 @@ export function acquire(store, taskRef, runner, options) {
60
61
  // copy, so a second replay cannot double-stamp, and Store.replay's T→T
61
62
  // contract holds for every other user.
62
63
  const replayed = store.hasMutationRecord(options.mutation ?? {});
63
- const result = inTransaction(store, () => acquireLocked(store, taskRef, runner, options));
64
+ const result = inTransaction(store, () => acquireLocked(store, taskRef, runner, options, "refuse"));
64
65
  return replayed && result.ok ? { ...result, replayed: true } : result;
65
66
  }
66
67
  /**
@@ -68,7 +69,10 @@ export function acquire(store, taskRef, runner, options) {
68
69
  * `acquireIfReady` needs its readiness check and the CAS to be one atomic
69
70
  * step, and SQLite does not nest transactions.
70
71
  */
71
- function acquireLocked(store, taskRef, runner, options) {
72
+ function acquireLocked(store, taskRef, runner, options,
73
+ /** Not an AcquireOptions field, so no options object (CLI flag, HTTP body, chat, MCP) can carry it. Only
74
+ * acquireForCheckCleanup passes "check-cleanup", after proving its check row in this transaction. */
75
+ closed) {
72
76
  const { now, ttlMs = DEFAULT_LEASE_MS, newLeaseId = randomUUID, mutation = {} } = options;
73
77
  const db = store.handle;
74
78
  // Idempotency wraps the CAS, not the other way round: a retried
@@ -158,6 +162,18 @@ function acquireLocked(store, taskRef, runner, options) {
158
162
  until: String(existing["expires_at"]),
159
163
  };
160
164
  }
165
+ // A CLOSED TASK claims for nobody (docs/plans/closed-task-claims.md, decision A), read here in the same write
166
+ // transaction as the insert below. Placed after every older refusal, so each of those answers as before; this
167
+ // only replaces what would have been a grant. A built-in reference with no task row has nothing to claim. A
168
+ // task of another backend keeps today's answer: its state is in that tracker, not in this database.
169
+ if (placedRef !== null && placedRef.backend === BUILT_IN) {
170
+ const known = taskStateForClaim(store, taskRef);
171
+ if (known.kind === "missing")
172
+ return { ok: false, reason: "no-task" };
173
+ if (known.kind === "built-in" && isClosedState(known.state) && closed === "refuse") {
174
+ return { ok: false, reason: "closed", state: known.state };
175
+ }
176
+ }
161
177
  const generation = existing === undefined ? 1 : Number(existing["lease_generation"]) + 1;
162
178
  const claim = {
163
179
  taskRef,
@@ -227,7 +243,7 @@ export function acquireForCheck(store, taskRef, runner, options) {
227
243
  if (registered !== null && store.liveClaimCount(runner, options.now) >= registered.runner.capacity) {
228
244
  return { ok: false, reason: "capacity", message: `${runner} has no free slot for a check` };
229
245
  }
230
- return acquireLocked(store, taskRef, runner, options);
246
+ return acquireLocked(store, taskRef, runner, options, "refuse");
231
247
  });
232
248
  }
233
249
  export function acquireIfReady(store, taskRef, runner, options) {
@@ -344,7 +360,7 @@ export function acquireIfReady(store, taskRef, runner, options) {
344
360
  };
345
361
  }
346
362
  }
347
- const taken = acquireLocked(store, taskRef, runner, options);
363
+ const taken = acquireLocked(store, taskRef, runner, options, "refuse");
348
364
  // The half-open probe slot is consumed only WITH the claim it admits —
349
365
  // an attention refusal or a lost CAS must not re-arm the quota as
350
366
  // exhausted with no probe in flight (finding 3). Same transaction, so
@@ -364,6 +380,12 @@ export function heartbeat(store, leaseId, now, ttlMs = DEFAULT_LEASE_MS) {
364
380
  const db = store.handle;
365
381
  const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
366
382
  return inTransaction(store, () => {
383
+ // An expired claim is not brought back on a task closed meanwhile (closed-task-claims.md, finding 1): that would
384
+ // be a new claim on a closed task by another road. A live claim, and an expired one on an open task, renew as
385
+ // always. Read in this write transaction, before the renewal.
386
+ const closedNow = expiredOnClosedTask(store, leaseId, now);
387
+ if (closedNow !== null)
388
+ return { ok: false, reason: "closed", state: closedNow };
367
389
  const { changes } = db
368
390
  .prepare(`UPDATE claim SET heartbeat_at = ?, expires_at = ?
369
391
  WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
@@ -582,7 +604,7 @@ function sealComplete(store, leaseId, args) {
582
604
  store.applyCancellation(String(taskId["external_id"]), { kind: "machine", code: "disowned-completion" }, now, null);
583
605
  }
584
606
  else {
585
- db.prepare("UPDATE task SET state = ?, updated_at = ? WHERE id = ?").run(state, now.toISOString(), String(taskId["external_id"]));
607
+ moveTask(db, String(taskId["external_id"]), "complete", now, state);
586
608
  }
587
609
  return true;
588
610
  });
@@ -789,11 +811,9 @@ function sealInterrupted(store, leaseId, args) {
789
811
  return { ok: true, stopRun, requeued: false, fenced: true };
790
812
  // Back to the queue, where the stop's hold keeps it — a paused task
791
813
  // reads as queued-and-held, exactly like an operator pause.
792
- const requeued = db
793
- .prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'")
794
- .run(now.toISOString(), taskId);
814
+ const requeued = moveTask(db, taskId, "requeue", now);
795
815
  store.bumpWake();
796
- return { ok: true, stopRun, requeued: Number(requeued.changes) > 0, fenced: false };
816
+ return { ok: true, stopRun, requeued, fenced: false };
797
817
  }
798
818
  /**
799
819
  * Hand an attempt back because the SERVICE stopped (its builder is
@@ -824,11 +844,9 @@ function sealHandBack(store, leaseId, args) {
824
844
  store.finishRun(runId, { outcome: "failed", reason: "interrupted", now });
825
845
  if (fenced)
826
846
  return { ok: true, requeued: false, fenced: true };
827
- const requeued = db
828
- .prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'")
829
- .run(now.toISOString(), taskId);
847
+ const requeued = moveTask(db, taskId, "requeue", now);
830
848
  store.bumpWake();
831
- return { ok: true, requeued: Number(requeued.changes) > 0, fenced: false };
849
+ return { ok: true, requeued, fenced: false };
832
850
  }
833
851
  /**
834
852
  * THE STOP FENCE at settlement (v52): when a stop applies to the run —
@@ -868,7 +886,6 @@ export function interruptIfStopped(store, args) {
868
886
  */
869
887
  function sealFailure(store, leaseId, args) {
870
888
  const { runId, taskId, failureClass, message, now } = args;
871
- const db = store.handle;
872
889
  const run = openAttempt(store, leaseId, runId, "a failure", "builder");
873
890
  const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
874
891
  if (stopped !== null)
@@ -916,6 +933,11 @@ function sealFailure(store, leaseId, args) {
916
933
  }
917
934
  const strikes = store.addStrike(run.taskRef);
918
935
  if (strikes >= MAX_STRIKES) {
936
+ // The state first: a task a person already closed keeps its state, and then no incident, hold or page is
937
+ // written either (closed-task-claims.md, decision B). The run's outcome, the release and the strike stand.
938
+ const closedState = exhaustUnlessClosed(store, run, taskId, now);
939
+ if (closedState !== null)
940
+ return { ok: true, disposition: "closed", strikes, state: closedState };
919
941
  const incidentId = store.createIncident({ run: runId, kind: "attempts-exhausted" }, now);
920
942
  store.holdOwned({
921
943
  taskRef: run.taskRef,
@@ -924,7 +946,6 @@ function sealFailure(store, leaseId, args) {
924
946
  reason: `attempts-exhausted — ${strikes} consecutive failures, last: ${failureClass}`,
925
947
  until: null,
926
948
  }, now);
927
- db.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ?").run(now.toISOString(), taskId);
928
949
  store.enqueueNotification({
929
950
  source: { run: runId },
930
951
  dedupeKey: `stalled:${run.taskRef}`,
@@ -970,7 +991,7 @@ function sealFailure(store, leaseId, args) {
970
991
  */
971
992
  function sealAuthExpired(store, run, taskId, now) {
972
993
  store.finishRun(run.id, { outcome: "failed", reason: "auth-expired", now });
973
- store.handle.prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'").run(now.toISOString(), taskId);
994
+ moveTask(store.handle, taskId, "requeue", now);
974
995
  pauseForAuth(store, { provider: run.provider, authMode: run.authMode ?? null, runId: run.id, taskRef: run.taskRef, now });
975
996
  store.bumpWake();
976
997
  return { disposition: "auth-expired", provider: run.provider };
@@ -1385,7 +1406,6 @@ function sealScout(store, leaseId, args) {
1385
1406
  */
1386
1407
  function sealScoutFailure(store, leaseId, args) {
1387
1408
  const { runId, taskId, failure: kind, message, now } = args;
1388
- const db = store.handle;
1389
1409
  const run = openAttempt(store, leaseId, runId, "a failure", "scout");
1390
1410
  const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1391
1411
  if (stopped !== null)
@@ -1419,6 +1439,10 @@ function sealScoutFailure(store, leaseId, args) {
1419
1439
  }
1420
1440
  const strikes = store.addStrike(run.taskRef);
1421
1441
  if (strikes >= MAX_STRIKES) {
1442
+ // Same as the build: a task a person already closed keeps its state, with no incident, hold or page.
1443
+ const closedState = exhaustUnlessClosed(store, run, taskId, now);
1444
+ if (closedState !== null)
1445
+ return { ok: true, disposition: "closed", strikes, state: closedState };
1422
1446
  const incidentId = store.createIncident({ run: runId, kind: "attempts-exhausted" }, now);
1423
1447
  store.holdOwned({
1424
1448
  taskRef: run.taskRef,
@@ -1427,7 +1451,6 @@ function sealScoutFailure(store, leaseId, args) {
1427
1451
  reason: `attempts-exhausted — ${strikes} consecutive scouting failures`,
1428
1452
  until: null,
1429
1453
  }, now);
1430
- db.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ?").run(now.toISOString(), taskId);
1431
1454
  store.enqueueNotification({
1432
1455
  source: { run: runId },
1433
1456
  dedupeKey: `stalled:${run.taskRef}`,
@@ -1512,3 +1535,53 @@ function inTransaction(store, body) {
1512
1535
  // intent) joins one transaction instead of dying on a nested BEGIN.
1513
1536
  return store.transact(body);
1514
1537
  }
1538
+ /**
1539
+ * Whether `leaseId` is an expired claim, still unreleased and the newest for its task, on a built-in task that is now
1540
+ * done, failed or cancelled: that closed state, else null. heartbeat refuses to renew exactly this case.
1541
+ */
1542
+ function expiredOnClosedTask(store, leaseId, now) {
1543
+ const row = store.handle
1544
+ .prepare(`SELECT task_ref FROM claim WHERE lease_id = ? AND released_at IS NULL AND expires_at <= ? AND ${NOT_SUPERSEDED}`)
1545
+ .get(leaseId, now.toISOString());
1546
+ if (row === undefined)
1547
+ return null;
1548
+ const known = taskStateForClaim(store, Number(row["task_ref"]));
1549
+ return known.kind === "built-in" && isClosedState(known.state) ? known.state : null;
1550
+ }
1551
+ /**
1552
+ * The stall's state write (build and scouting), inside finalize's transaction. The task is the run's own: a supplied
1553
+ * id that names another task is a caller defect, thrown like openAttempt's. An open built-in task is marked failed
1554
+ * (null back, the stall goes on as before). One a person already closed keeps its state: that state comes back and
1555
+ * the caller writes no incident, hold or page. A reference with no built-in task row behind it (a missing row, or a
1556
+ * task of another backend) writes no state and answers null, so the stall goes on as it did before.
1557
+ */
1558
+ function exhaustUnlessClosed(store, run, taskId, now) {
1559
+ const ref = store.refForId(run.taskRef);
1560
+ if (ref === null || ref.externalId !== taskId) {
1561
+ throw new Error(`run ${run.id} belongs to task reference ${run.taskRef}, not ${taskId} — a stall is sealed on the run's own task`);
1562
+ }
1563
+ if (ref.backend !== BUILT_IN)
1564
+ return null;
1565
+ if (moveTask(store.handle, taskId, "exhaust", now))
1566
+ return null;
1567
+ const state = store.getTask(taskId)?.state;
1568
+ return state !== undefined && isClosedState(state) ? state : null;
1569
+ }
1570
+ /**
1571
+ * Check cleanup's claim (acp-check.ts settleChecks): the one road that may claim a closed task. Only the task bound to
1572
+ * check `checkId`, when that check is this runner's, finished and unsettled, held right now by this cleanup `attempt`,
1573
+ * and its task is the built-in one named from the check row's own id (store.ts checkCleanupTaskOf) — proved here, in
1574
+ * the same transaction as the grant. Every other gate of the claim still applies. When the proof fails, the claim
1575
+ * answers exactly as an ordinary one would: never wider.
1576
+ */
1577
+ export function acquireForCheckCleanup(store, checkId, attempt, runner, options) {
1578
+ return inTransaction(store, () => {
1579
+ const proved = checkCleanupTaskOf(store, checkId, runner, attempt, options.now);
1580
+ if (proved !== null)
1581
+ return acquireLocked(store, proved, runner, options, "check-cleanup");
1582
+ const bound = store.acpCheck(checkId)?.taskRef ?? null;
1583
+ if (bound === null)
1584
+ return { ok: false, reason: "no-task" };
1585
+ return acquireLocked(store, bound, runner, options, "refuse");
1586
+ });
1587
+ }
@@ -26,6 +26,9 @@ CREATE TABLE IF NOT EXISTS chat_pair (
26
26
  );
27
27
  -- Everything an app sent us, once each (the key is the idempotency). kind 'update' is an applied (or pushed and
28
28
  -- waiting) Telegram update; 'message' is a message the lead answers, queued to running to done or failed.
29
+ -- done means "no more work for the lead", not "the person has the reply": Telegram writes it once every part is sent,
30
+ -- Slack, Discord and Teams when the reply is planned. result is a word from chat-message-states.ts; whether a reply
31
+ -- reached the person is answered there by unsent(), from chat_part.
29
32
  -- session: the lead session its turn was dispatched under, a pointer to its receipt (never an authority).
30
33
  CREATE TABLE IF NOT EXISTS chat_event (
31
34
  ${PROVIDER}, id TEXT NOT NULL, installation TEXT NOT NULL, binding INTEGER,