Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
df3d434
feat(scheduled-tasks): replay dedupe and coalescing for webhook deliv…
juliusmarminge Oct 20, 2026
d9cf621
test(server): include webhook dedupe in migration expectations
juliusmarminge Oct 20, 2026
4367d23
fix(scheduled-tasks): bound dedupe writes and recover webhook dispatch
juliusmarminge Oct 20, 2026
b8c0d8e
fix(scheduled-tasks): bound webhook failures and preserve coalesced p…
juliusmarminge Oct 20, 2026
db9c400
Merge remote-tracking branch 'origin/main' into feat/webhook-dedupe
juliusmarminge Oct 20, 2026
2f52fa6
fix(scheduled-tasks): refund only the matching webhook rate reservation
juliusmarminge Oct 20, 2026
340e762
fix(scheduled-tasks): preserve dispatched webhook deliveries during r…
juliusmarminge Oct 20, 2026
c624525
fix(scheduled-tasks): retain acknowledged coalesced relay claims
juliusmarminge Oct 20, 2026
94edd65
fix(scheduled-tasks): bound webhook windows and recover all delivery …
juliusmarminge Oct 20, 2026
55e6d07
fix(scheduled-tasks): accept dedupe hits at full run budget
juliusmarminge Oct 20, 2026
1e76337
fix(scheduled-tasks): preserve webhook delivery labels and payload de…
juliusmarminge Oct 20, 2026
02e2174
fix(scheduled-tasks): recover render defects and count the active del…
juliusmarminge Oct 20, 2026
90a67c1
fix(web): clarify pending webhook window details
juliusmarminge Oct 20, 2026
1ba4a66
chore(scheduled-tasks): merge main webhook filters into dedupe
juliusmarminge Oct 20, 2026
e2a5194
fix(contracts): bound webhook dedupe template validation
juliusmarminge Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,13 @@ function TaskForm({
Dialog.alert("Invalid filter", parsedFilters.error);
return;
}
if (draft.schedule.mode === "webhook" && schedule === null) {
Dialog.alert(
"Invalid webhook settings",
"Each dedupe key must contain a valid request placeholder. The coalesce window must be whole seconds from 10 to 3600, or blank.",
);
return;
}
if (
!draft.title.trim() ||
!draft.prompt.trim() ||
Expand Down Expand Up @@ -1060,6 +1067,41 @@ function TaskForm({
setDraft({ ...draft, schedule: { ...draft.schedule, filters } })
}
/>
<FormField
label="Replay key template"
value={draft.schedule.replayKey}
placeholder="{{headers.delivery-id}}"
disabled={saving}
borderTop
onChange={(replayKey) =>
setDraft({ ...draft, schedule: { ...draft.schedule, replayKey } })
}
/>
<FormField
label="Coalesce key template"
value={draft.schedule.coalesceKey}
placeholder="{{body.id}}"
disabled={saving}
borderTop
onChange={(coalesceKey) =>
setDraft({ ...draft, schedule: { ...draft.schedule, coalesceKey } })
}
/>
<FormField
label="Coalesce window (seconds, 10–3600)"
value={draft.schedule.coalesceWindowSeconds}
placeholder="120"
keyboardType="number-pad"
disabled={saving}
borderTop
onChange={(coalesceWindowSeconds) =>
setDraft({ ...draft, schedule: { ...draft.schedule, coalesceWindowSeconds } })
}
/>
<Text className="px-4 pb-3 text-sm text-foreground-muted">
Replay keys suppress repeats for 7 days. Coalescing runs the latest payload when the
window from the first delivery closes. Empty keys run normally.
</Text>
</>
) : (
<>
Expand Down
44 changes: 43 additions & 1 deletion apps/mobile/src/features/settings/scheduledTaskDraft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,45 @@ describe("scheduleDraftForTask", () => {
expect(scheduleFromDraft(scheduleDraftForTask({ schedule }))).toEqual(schedule);
});

it("round-trips dedupe settings and rejects invalid windows", () => {
const dedupe = {
replayKey: "{{headers.delivery-id}}",
coalesceKey: "{{body.id}}",
coalesceWindowSeconds: 3600,
};
const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null, dedupe } });
expect(scheduleFromDraft(draft)).toMatchObject({ dedupe });
expect(
scheduleFromDraft({
...draft,
replayKey: " \n ",
coalesceKey: " \t ",
coalesceWindowSeconds: "",
}),
).toMatchObject({ dedupe: {} });
expect(
scheduleFromDraft({
...draft,
replayKey: ` ${dedupe.replayKey} `,
coalesceKey: `\t${dedupe.coalesceKey}\n`,
}),
).toMatchObject({ dedupe });
expect(
scheduleFromDraft({ ...draft, replayKey: "", coalesceKey: "", coalesceWindowSeconds: "" }),
).toMatchObject({ dedupe: {} });
for (const coalesceWindowSeconds of ["9", "3601", "1.5", "abc"]) {
expect(scheduleFromDraft({ ...draft, coalesceWindowSeconds })).toBeNull();
}
});

it("round-trips a webhook schedule without a signature", () => {
const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null } });
expect(draft.mode).toBe("webhook");
expect(scheduleFromDraft(draft)).toEqual({
type: "webhook",
signature: null,
maxDeliveryAgeMinutes: null,
dedupe: {},
});
});

Expand All @@ -50,6 +82,7 @@ describe("scheduleDraftForTask", () => {
expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: "0" })).toBeNull();
expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: "" })).toMatchObject({
maxDeliveryAgeMinutes: null,
dedupe: {},
});
});

Expand All @@ -62,7 +95,7 @@ describe("scheduleDraftForTask", () => {
const saved = scheduleFromDraft(
scheduleDraftForTask({ schedule: { type: "webhook", signature } }),
);
expect(saved).toEqual({ type: "webhook", signature, maxDeliveryAgeMinutes: null });
expect(saved).toEqual({ type: "webhook", signature, maxDeliveryAgeMinutes: null, dedupe: {} });
expect(saved?.type === "webhook" && saved.signature && "secret" in saved.signature).toBe(false);
});

Expand Down Expand Up @@ -369,3 +402,12 @@ describe("scheduled task model defaults", () => {
).toBeNull();
});
});

it("rejects dedupe keys without valid request placeholders", () => {
expect(
scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "webhook", replayKey: "literal" }),
).toBeNull();
expect(
scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "webhook", coalesceKey: "{{body.bad" }),
).toBeNull();
});
38 changes: 38 additions & 0 deletions apps/mobile/src/features/settings/scheduledTaskDraft.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isWebhookDedupeTemplate } from "@t3tools/contracts";
import type {
ModelSelection,
ServerConfig,
Expand Down Expand Up @@ -56,6 +57,9 @@ export type ScheduleDraft = {
readonly intervalMinutes: string;
readonly signature: ScheduledTaskWebhookSignature | null;
readonly maxDeliveryAgeMinutes: string;
readonly replayKey: string;
readonly coalesceKey: string;
readonly coalesceWindowSeconds: string;
readonly filters: ReadonlyArray<WebhookFilterDraft>;
/** The filter rows the form opened with. */
readonly savedFilters: ReadonlyArray<WebhookFilterDraft>;
Expand All @@ -68,6 +72,9 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = {
intervalMinutes: "15",
signature: null,
maxDeliveryAgeMinutes: "",
replayKey: "",
coalesceKey: "",
coalesceWindowSeconds: "",
filters: [],
savedFilters: [],
};
Expand All @@ -93,6 +100,12 @@ export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): Sch
...DEFAULT_SCHEDULE,
mode: "webhook",
signature: task.schedule.signature,
replayKey: task.schedule.dedupe?.replayKey ?? "",
coalesceKey: task.schedule.dedupe?.coalesceKey ?? "",
coalesceWindowSeconds:
task.schedule.dedupe?.coalesceWindowSeconds === undefined
? ""
: String(task.schedule.dedupe.coalesceWindowSeconds),
maxDeliveryAgeMinutes:
task.schedule.maxDeliveryAgeMinutes == null
? ""
Expand All @@ -107,6 +120,19 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche
if (draft.mode === "webhook") {
const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes);
if (maxDeliveryAgeMinutes === undefined) return null;
const replayKey = draft.replayKey.trim();
const coalesceKey = draft.coalesceKey.trim();
if ([replayKey, coalesceKey].some((key) => key && !isWebhookDedupeTemplate(key))) return null;
const coalesceWindowSeconds = draft.coalesceWindowSeconds?.trim()
? Number(draft.coalesceWindowSeconds)
: undefined;
if (
coalesceWindowSeconds !== undefined &&
(!Number.isInteger(coalesceWindowSeconds) ||
coalesceWindowSeconds < 10 ||
coalesceWindowSeconds > 3600)
)
return null;
const parsedFilters = parseWebhookFilterDrafts(draft.filters);
if (!("filters" in parsedFilters)) return null;
// No secret is sent, so the server keeps the stored one.
Expand All @@ -121,6 +147,15 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche
prefix: draft.signature.prefix,
},
maxDeliveryAgeMinutes,
...(replayKey || coalesceKey || coalesceWindowSeconds !== undefined
? {
dedupe: {
...(replayKey ? { replayKey } : {}),
...(coalesceKey ? { coalesceKey } : {}),
...(coalesceWindowSeconds === undefined ? {} : { coalesceWindowSeconds }),
},
}
: { dedupe: {} }),
// Only edited filters are sent; the server keeps the stored ones otherwise.
...(webhookFilterDraftsChanged(draft.savedFilters, draft.filters)
? { filters: parsedFilters.filters }
Expand Down Expand Up @@ -179,6 +214,9 @@ function draftSignature(draft: ScheduledTaskDraft): string {
[...draft.schedule.weekdays].sort((a, b) => a - b),
draft.schedule.intervalMinutes,
draft.schedule.maxDeliveryAgeMinutes,
draft.schedule.replayKey,
draft.schedule.coalesceKey,
draft.schedule.coalesceWindowSeconds,
draft.schedule.filters.map(({ path, op, value }) => [path, op, value]),
draft.workspace,
draft.baseRef,
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/mcp/toolkits/orchestrator/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", {

export const ScheduleTaskTool = Tool.make("schedule_task", {
description:
"Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.",
"Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.",
parameters: OrchestratorMcpScheduleTaskInput,
success: OrchestratorMcpScheduleTaskResult,
failure: OrchestratorMcpFailure,
Expand All @@ -143,7 +143,7 @@ const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", {

const UpdateScheduledTaskTool = Tool.make("update_scheduled_task", {
description:
"Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. A webhook schedule without filters keeps the stored filters; pass filters to replace them, or filters:[] to clear them.",
"Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Webhook schedule dedupe accepts replayKey, coalesceKey, and coalesceWindowSeconds (10–3600, default 120) to suppress replays for 7 days and retain the latest payload in each fixed window. Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. A webhook schedule without filters keeps the stored filters; pass filters to replace them, or filters:[] to clear them.",
parameters: OrchestratorMcpUpdateScheduledTaskInput,
success: OrchestratorMcpScheduleTaskResult,
failure: OrchestratorMcpFailure,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import Migration0063 from "./Migrations/063_DropUnusedV1Projections.ts";
import Migration0064 from "./Migrations/064_CanonicalizeThreadPullRequestLinks.ts";
import Migration0065 from "./Migrations/065_CodexUsageNativeTurnIndex.ts";
import Migration0066 from "./Migrations/066_RepairClientSettlementTimestamps.ts";
import Migration0067 from "./Migrations/067_WebhookDedupe.ts";

export const migrationEntries = [
[1, "OrchestrationEvents", Migration0001],
Expand Down Expand Up @@ -138,6 +139,7 @@ export const migrationEntries = [
[64, "CanonicalizeThreadPullRequestLinks", Migration0064],
[65, "CodexUsageNativeTurnIndex", Migration0065],
[66, "RepairClientSettlementTimestamps", Migration0066],
[67, "WebhookDedupe", Migration0067],
] as const;

export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ layer("055_OrchestrationV2", (it) => {
Effect.sync(() => {
assert.deepStrictEqual(
migrationEntries.map(([id]) => id),
Array.from({ length: 66 }, (_, index) => index + 1),
Array.from({ length: 67 }, (_, index) => index + 1),
);
}),
);
Expand All @@ -40,6 +40,7 @@ layer("055_OrchestrationV2", (it) => {
[64, "CanonicalizeThreadPullRequestLinks"],
[65, "CodexUsageNativeTurnIndex"],
[66, "RepairClientSettlementTimestamps"],
[67, "WebhookDedupe"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);

Expand Down Expand Up @@ -72,6 +73,7 @@ layer("055_OrchestrationV2", (it) => {
{ migration_id: 64, name: "CanonicalizeThreadPullRequestLinks" },
{ migration_id: 65, name: "CodexUsageNativeTurnIndex" },
{ migration_id: 66, name: "RepairClientSettlementTimestamps" },
{ migration_id: 67, name: "WebhookDedupe" },
]);

const tables = yield* sql<{ readonly name: string }>`
Expand Down
29 changes: 29 additions & 0 deletions apps/server/src/persistence/Migrations/067_WebhookDedupe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`ALTER TABLE scheduled_tasks ADD COLUMN last_run_webhook_delivery_id TEXT`;
yield* sql`CREATE TABLE scheduled_task_webhook_replay_keys (
task_id TEXT NOT NULL REFERENCES scheduled_tasks(task_id) ON DELETE CASCADE,
replay_key TEXT NOT NULL,
delivery_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY (task_id, replay_key)
)`;
yield* sql`CREATE INDEX idx_webhook_replay_expiry ON scheduled_task_webhook_replay_keys(expires_at)`;
yield* sql`CREATE INDEX idx_webhook_replay_delivery ON scheduled_task_webhook_replay_keys(delivery_id)`;
yield* sql`CREATE TABLE scheduled_task_webhook_pending (
delivery_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES scheduled_tasks(task_id) ON DELETE CASCADE,
coalesce_key TEXT NOT NULL,
due_at INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'pending',
task_json TEXT NOT NULL,
request_json TEXT NOT NULL,
delivery_ids_json TEXT NOT NULL
)`;
yield* sql`CREATE INDEX idx_webhook_pending_due ON scheduled_task_webhook_pending(state, due_at)`;
yield* sql`CREATE UNIQUE INDEX idx_webhook_pending_key
ON scheduled_task_webhook_pending(task_id, coalesce_key) WHERE state = 'pending'`;
});
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe("V2 preview upgrade", () => {
[64, "CanonicalizeThreadPullRequestLinks"],
[65, "CodexUsageNativeTurnIndex"],
[66, "RepairClientSettlementTimestamps"],
[67, "WebhookDedupe"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
Expand Down Expand Up @@ -135,6 +136,7 @@ describe("V2 preview upgrade", () => {
[64, "CanonicalizeThreadPullRequestLinks"],
[65, "CodexUsageNativeTurnIndex"],
[66, "RepairClientSettlementTimestamps"],
[67, "WebhookDedupe"],
]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
Expand Down
Loading
Loading