toolroll 0.9.18 → 0.9.19

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.19 — 2026-10-03
4
+
5
+ - A release candidate whose check was rerun on the same commit can be
6
+ deployed; no fresh gate needed.
7
+ - Screenshots with results follow their result message and thread under it on
8
+ Slack and Discord, never sent twice.
9
+ - A process-cleanup test no longer flakes under load.
10
+
3
11
  ## 0.9.18 — 2026-10-03
4
12
 
5
13
  - **Decide in Telegram.** A ready result's Accept and finish, Request changes
@@ -677,13 +677,21 @@ function planResultShots(options, binding, notification, repos, id, now) {
677
677
  if (plan.kind === "none" || notification.taskId === null || notification.run === null)
678
678
  return;
679
679
  const where = { task: notification.taskId, run: notification.run };
680
- // The result's message here: its finished-work batch (quiet), or its progress card (every step).
680
+ // The result's message here: its finished-work batch (quiet), its progress card (every step), or the result's own
681
+ // notice. The transport threads the screenshots under it once it is posted, and holds them until then.
681
682
  const follows = (() => {
682
683
  const batch = notification.taskRef === null ? null : store.chatBatchMessageFor(`${state.channel}:${binding.id}`, notification.taskRef, notification.run);
683
684
  if (batch !== null)
684
685
  return Number(batch);
685
686
  const card = state.prepare("SELECT part FROM chat_progress WHERE binding=? AND run=?").get(binding.id, notification.run);
686
- return card === undefined ? null : Number(card.part);
687
+ if (card !== undefined)
688
+ return Number(card.part);
689
+ for (const one of store.handle.prepare("SELECT id FROM notification WHERE source_run=? AND recipient IS NULL AND id<? ORDER BY id DESC").all(notification.run, notification.id)) {
690
+ const part = state.prepare("SELECT id FROM chat_part WHERE event=? ORDER BY id LIMIT 1").get(chatHash(`${state.channel}:notice:${binding.id}:${Number(one["id"])}`));
691
+ if (part !== undefined)
692
+ return Number(part.id);
693
+ }
694
+ return null;
687
695
  })();
688
696
  const image = (artifact, sha256) => ({ taskId: where.task, run: where.run, artifact, sha256 });
689
697
  const parts = plan.kind === "line" ? [{ text: plan.text, ...where }]
@@ -2,7 +2,7 @@
2
2
  import { chatQuestionButtons } from "./teammate-question.js";
3
3
  import { chatAskButtons } from "./chat-ask.js";
4
4
  import { chatFlowButtons } from "./chat-flow.js";
5
- import { refuseResultShots, resultShotsPruned } from "./result-shots.js";
5
+ import { refuseResultShots, resultShotsPruned, shotWaitsUntil } from "./result-shots.js";
6
6
  import { channelInbox } from "./chat-inbox.js";
7
7
  import { roomCommand } from "./chat-rooms.js";
8
8
  import { ChatState, ChatDeliveryError, chatHash, } from "./chat-delivery-state.js";
@@ -210,8 +210,13 @@ export async function deliverDiscordPart(options) {
210
210
  state.prepare("UPDATE chat_part SET state='dropped',problem='Removed by retention' WHERE id=?").run(row.id);
211
211
  return true;
212
212
  }
213
- // A screenshot sent with a result replies to that result's message once it is placed.
214
- const follows = content.shot?.follows == null ? undefined : state.prepare("SELECT message FROM chat_part WHERE id=? AND state='sent'").get(content.shot.follows)?.message;
213
+ // A screenshot sent with a result replies to that result's message once it is placed, and waits until then.
214
+ const result = content.shot?.follows == null ? undefined : state.prepare("SELECT message,state,next_at FROM chat_part WHERE id=?").get(content.shot.follows);
215
+ if (result !== undefined && !discordId(result["message"]) && result["state"] === "pending") {
216
+ state.prepare("UPDATE chat_part SET next_at=? WHERE id=?").run(shotWaitsUntil(result["next_at"], now), row.id);
217
+ return true;
218
+ }
219
+ const follows = result?.["message"];
215
220
  const thread = discordId(follows) ? String(follows) : event.thread;
216
221
  if (content.image) {
217
222
  const image = verifyResultImage(store, options.evidenceRoot, repos, content.image);
@@ -39,6 +39,8 @@ export type ResultShotsPlan =
39
39
  * no wait in between, or asks again.
40
40
  */
41
41
  export declare function resultShotsFor(store: Store, evidenceRoot: string | undefined, repos: readonly string[], row: Pick<Notification, "kind" | "recipient" | "taskId" | "taskRef" | "run">, sent?: ReadonlySet<number>): ResultShotsPlan;
42
+ /** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
43
+ export declare function shotWaitsUntil(resultNextAt: unknown, now: Date): string;
42
44
  /** Retention removed this result's files: its screenshots go quietly, never as a complaint. */
43
45
  export declare function resultShotsPruned(evidenceRoot: string, run: number): boolean;
44
46
  /**
@@ -102,6 +102,11 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
102
102
  const remaining = shots.filter(one => !sent.has(one.artifact));
103
103
  return remaining.length === 0 ? { kind: "none", why: "sent" } : { kind: "send", taskId: row.taskId, run: row.run, shots: remaining };
104
104
  }
105
+ /** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
106
+ export function shotWaitsUntil(resultNextAt, now) {
107
+ const soonest = new Date(now.getTime() + 1_000).toISOString();
108
+ return typeof resultNextAt === "string" && resultNextAt > soonest ? resultNextAt : soonest;
109
+ }
105
110
  /** Retention removed this result's files: its screenshots go quietly, never as a complaint. */
106
111
  export function resultShotsPruned(evidenceRoot, run) {
107
112
  return existsSync(join(evidenceRoot, String(run), RETENTION_NOTE));
@@ -1,5 +1,5 @@
1
1
  import { chatFlowButtons } from "./chat-flow.js";
2
- import { refuseResultShots, resultShotsPruned } from "./result-shots.js";
2
+ import { refuseResultShots, resultShotsPruned, shotWaitsUntil } from "./result-shots.js";
3
3
  import { chatQuestionButtons } from "./teammate-question.js";
4
4
  import { chatAskButtons } from "./chat-ask.js";
5
5
  import { channelInbox } from "./chat-inbox.js";
@@ -233,8 +233,13 @@ export async function deliverSlackPart(options) {
233
233
  if (content.task &&
234
234
  !repos.includes(store.lookupRef(content.task)?.repo ?? ""))
235
235
  throw new SlackError("Connected projects changed");
236
- // A screenshot sent with a result goes in that result's thread once its message is placed.
237
- const follows = content.shot?.follows == null ? undefined : state.db.prepare("SELECT message FROM slack_part WHERE id=? AND state='sent'").get(content.shot.follows)?.message;
236
+ // A screenshot sent with a result goes in that result's thread once its message is placed, and waits until then.
237
+ const result = content.shot?.follows == null ? undefined : state.db.prepare("SELECT message,state,next_at FROM slack_part WHERE id=?").get(content.shot.follows);
238
+ if (result !== undefined && !slackTs(result["message"]) && result["state"] === "pending") {
239
+ state.db.prepare("UPDATE slack_part SET next_at=? WHERE id=?").run(shotWaitsUntil(result["next_at"], now), row.id);
240
+ return true;
241
+ }
242
+ const follows = result?.["message"];
238
243
  const thread = slackTs(follows) ? String(follows) : event.thread;
239
244
  if (content.image && content.shot && resultShotsPruned(options.evidenceRoot, content.image.run)) {
240
245
  state.db.prepare("UPDATE slack_part SET state='dropped',problem='Removed by retention' WHERE id=?").run(row.id);
package/dist/telegram.js CHANGED
@@ -690,7 +690,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
690
690
  const groups = [];
691
691
  for (const row of claimed) {
692
692
  const last = groups[groups.length - 1];
693
- if (!quiet && digest.everyMs !== null && !isUrgent(row) && last !== undefined && !isUrgent(last[0]))
693
+ if (!quiet && digest.everyMs !== null && joinsDigest(row) && last !== undefined && joinsDigest(last[0]))
694
694
  last.push(row);
695
695
  else
696
696
  groups.push([row]);
@@ -787,18 +787,26 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
787
787
  const sent = await sender(`A screenshot for result #${row.run} could not be sent: ${verified.problem}. Inspect the result before accepting.`);
788
788
  return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
789
789
  }
790
+ // Already sent with its result (result-shots.ts): the same screenshot is not sent again.
791
+ const artifact = Number(match[2]);
792
+ if (store.resultShotsSent(row.destination, row.run).has(artifact))
793
+ return { ok: true, receipt: "skipped:screenshot-sent" };
794
+ // Marked while it goes, so the screenshots sent with the result skip it; any failure unmarks it for the retry.
795
+ store.markResultShotsSent(row.destination, row.run, [artifact], clock());
790
796
  // No await between the access fence, file validation and upload.
791
797
  let answer;
792
798
  try {
793
799
  answer = await transport("sendDocument", { chat_id: binding.chatId, caption: `${resultTaskLabel(row.taskId)} · result #${row.run} · screenshot for acceptance` }, undefined, {
794
- field: "document", bytes: verified.bytes, contentType: verified.format === "png" ? "image/png" : "image/jpeg", fileName: resultImageFileName(row.taskId, row.run, Number(match[2]), verified.format),
800
+ field: "document", bytes: verified.bytes, contentType: verified.format === "png" ? "image/png" : "image/jpeg", fileName: resultImageFileName(row.taskId, row.run, artifact, verified.format),
795
801
  });
796
802
  }
797
803
  catch {
804
+ store.unmarkResultShotsSent(row.destination, row.run, [artifact]);
798
805
  return { ok: false, error: "Screenshot delivery is unconfirmed; retry may duplicate it" };
799
806
  }
800
807
  const messageId = answer.result?.message_id;
801
808
  if (!answer.ok || !Number.isSafeInteger(messageId)) {
809
+ store.unmarkResultShotsSent(row.destination, row.run, [artifact]);
802
810
  if (answer.parameters?.retry_after !== undefined)
803
811
  store.deferTelegram(botId, new Date(clock().getTime() + answer.parameters.retry_after * 1000).toISOString());
804
812
  return { ok: false, error: answer.uncertain || (answer.ok && messageId == null) ? "Screenshot delivery is unconfirmed; retry may duplicate it" : "Telegram did not accept the screenshot" };
@@ -856,7 +864,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
856
864
  }
857
865
  return { ok: true, receipt };
858
866
  };
859
- const batched = !quiet && digest.everyMs !== null && !isUrgent(rows[0]);
867
+ const batched = !quiet && digest.everyMs !== null && joinsDigest(rows[0]);
860
868
  const row = rows[0];
861
869
  const progressRun = batched ? null : store.telegramProgressRun(row);
862
870
  const progress = progressRun !== null && isTelegramProgressNotification(row);
@@ -1094,8 +1102,12 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
1094
1102
  }
1095
1103
  /** What pages singly whatever the cadence: a decision, or an attention-class fact. */
1096
1104
  function isUrgent(notification) {
1097
- return /^decision:\d+$/.test(notification.dedupeKey) || notification.pushClass === "attention" || notification.kind === "acceptance-evidence" || notification.kind === "acceptance-ready"
1098
- || notification.kind === RESULT_SHOTS_KIND;
1105
+ return /^decision:\d+$/.test(notification.dedupeKey) || notification.pushClass === "attention" || notification.kind === "acceptance-evidence" || notification.kind === "acceptance-ready";
1106
+ }
1107
+ /** A routine fact the digest may carry. Screenshots are never urgent (they follow their result message, which the
1108
+ * task's order fence holds them behind) and never a digest line: they go as their own photos once it has gone. */
1109
+ function joinsDigest(notification) {
1110
+ return !isUrgent(notification) && notification.kind !== RESULT_SHOTS_KIND;
1099
1111
  }
1100
1112
  /** The digest text: a header with the count and the window, then one
1101
1113
  * fact per entry — its subject, then its body's first line, indented.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.18",
3
+ "version": "0.9.19",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"