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 +8 -0
- package/dist/chat-delivery.js +10 -2
- package/dist/discord-chat.js +8 -3
- package/dist/result-shots.d.ts +2 -0
- package/dist/result-shots.js +5 -0
- package/dist/slack-chat.js +8 -3
- package/dist/telegram.js +17 -5
- package/package.json +1 -1
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
|
package/dist/chat-delivery.js
CHANGED
|
@@ -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),
|
|
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
|
-
|
|
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 }]
|
package/dist/discord-chat.js
CHANGED
|
@@ -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
|
|
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);
|
package/dist/result-shots.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/result-shots.js
CHANGED
|
@@ -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));
|
package/dist/slack-chat.js
CHANGED
|
@@ -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
|
|
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 &&
|
|
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,
|
|
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 &&
|
|
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
|
-
|
|
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.
|
|
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"
|