bulk_complete_tasks
Bulk-close cron-spam tasks safely with dry-run default and RBAC gate.
bulk_complete_tasks
Bulk-close tasks that match a filter in one atomic mutation. Introduced in PR-F (merged commit 4c068d2 after Eta REVISE round addressing blast-radius / scope / caller-gate hardening). Designed to safely drain cron-spam backlogs accumulated from auto-generated check-messages polling tasks.
dryRun defaults to true. The tool never mutates the database unless you explicitly pass dryRun: false. Always preview first to confirm the count, then call again with dryRun: false to commit. Closed tasks are irreversible — status is permanently set to done.
Safety contract (iter-2 hardening)
The mutation enforces three guardrails before any write:
| Guardrail | Throws | When |
|---|---|---|
| Reductive filter required | BULK_FILTER_TOO_BROAD | Neither filter.autoGeneratedOnly: true nor filter.assignedTo set — match-all is forbidden. |
| Caller required for live commit | BULK_CALLER_REQUIRED | dryRun: false with no callerOrchestrator — default-deny on the destructive path. |
| Blast-radius cap | BULK_HARD_CAP_EXCEEDED | Matched count exceeds BULK_COMPLETE_HARD_CAP = 500 — narrow the filter and retry. |
Backing implementation uses a withIndex("by_status") iterator with early-stop at cap+1 to count without scanning the full table.
Args
| Arg | Type | Default | Description |
|---|---|---|---|
filter | object | (required) | Filter object controlling which tasks are matched. MUST contain at least one reductive predicate (autoGeneratedOnly: true OR assignedTo: "<role>") — else throws BULK_FILTER_TOO_BROAD. |
filter.autoGeneratedOnly | boolean | false | When true, matches tasks where createdBy matches /^cron-/i OR title matches /^\/?check-messages$/i. |
filter.assignedTo | string | — | When set, narrows matches to tasks whose assignedTo equals this role. Combined with autoGeneratedOnly via AND. |
dryRun | boolean | true | Safety default. true returns a preview without mutating. Pass false explicitly to commit (requires callerOrchestrator). |
completionNoteTemplate | string | (see below) | Template string written as completionNote on each closed task. Supports {{day}}, {{bulkRunId}}, {{executedAt}} interpolation. Default: "bulk-cleanup: cron-spam day {{day}} runId={{bulkRunId}} executedAt={{executedAt}}". |
callerOrchestrator | string | — | Caller identity for RBAC. Required for dryRun: false (default-deny). When provided and not "system", every matched task must have createdBy or assignedTo equal to the caller. |
Returns
dryRun=true (preview — default)
{
count: number, // number of tasks that would be closed (≤ BULK_COMPLETE_HARD_CAP)
sampleIds: string[], // up to 10 matching task IDs
bulkRunId: string, // unique run ID (Day-76 evidence token, pre-generated)
cappedAt?: number // present iff matched count was truncated at the 500-cap; caller must narrow filter
}dryRun=false (commit)
{
count: number, // number of tasks closed
sampleIds: string[], // up to 10 closed task IDs
bulkRunId: string, // unique run ID used in every completionNote
executedAt: number // epoch ms when the mutation ran
}Examples
Dry-run preview (default behavior)
// call — dryRun=true is the default; this call never mutates
{
"filter": { "autoGeneratedOnly": true },
"callerOrchestrator": "system"
}
// response
{
"count": 152,
"sampleIds": ["k17abc...", "k17def...", "k17ghi..."],
"bulkRunId": "bulk-1782050000000-a3f2"
}Live bulk close with custom completion note template
// call — explicit dryRun=false with custom template
{
"filter": { "autoGeneratedOnly": true },
"dryRun": false,
"completionNoteTemplate": "bulk-cleanup: cron-spam day {{day}} runId={{bulkRunId}}",
"callerOrchestrator": "system"
}
// response
{
"count": 152,
"sampleIds": ["k17abc...", "k17def...", "k17ghi..."],
"bulkRunId": "bulk-1782050000000-a3f2",
"executedAt": 1782050000000
}RBAC-gated call (orchestrator-scoped)
// call — pi can only close tasks it created or was assigned
{
"filter": { "autoGeneratedOnly": true },
"dryRun": false,
"callerOrchestrator": "pi"
}
// response (all matched tasks belong to pi)
{
"count": 8,
"sampleIds": ["k17jkl...", "k17mno..."],
"bulkRunId": "bulk-1782050000001-c9d4",
"executedAt": 1782050000001
}Cron contract
The autoGeneratedOnly filter matches tasks that satisfy either predicate below. This is the same contract used by list_tasks excludeAutoGenerated (PR-E).
| Predicate | Pattern | Example matches | Example non-matches |
|---|---|---|---|
createdBy | /^cron-/i (dash mandatory) | cron-bot, cron-daily | cronus, cron (no dash) |
title | /^\/?check-messages$/i (whole-string, optional leading slash) | check-messages, /check-messages, CHECK-MESSAGES | check-messages-v2, run check-messages |
Filter placement: applied in-memory against all non-done tasks, before any mutation is executed.
Day-76 evidence-bound completionNote
Every task closed by bulk_complete_tasks receives a completionNote that satisfies the Day-76 Evidence-Bound Done doctrine. The default template injects two verifiable proof tokens:
{{day}}— project day number computed from epoch2026-03-06 UTC. Unique per calendar day.{{bulkRunId}}— formatbulk-<epochMs>-<randomHex4>. Unique per run, consistent across all tasks closed in that run.{{executedAt}}— epoch ms timestamp of the mutation.
Default note written to each task: "bulk-cleanup: cron-spam day {{day}} runId={{bulkRunId}} executedAt={{executedAt}}" (with values interpolated).
This means every closed task carries a traceable, auditable proof token — the bulkRunId links the batch, and day scopes it to a human-readable project timeline.
Callouts
Blast-radius cap: the iterator uses withIndex("by_status") to scan only non-done tasks with early-stop at BULK_COMPLETE_HARD_CAP + 1 = 501. Matched count above 500 throws BULK_HARD_CAP_EXCEEDED — narrow the filter (e.g. add assignedTo) and retry. Dry-run output carries cappedAt: 500 when truncation applies.
Reductive filter required: a filter with neither autoGeneratedOnly: true nor assignedTo set is rejected with BULK_FILTER_TOO_BROAD. Match-all is forbidden by design — destructive surface must always be scoped.
RBAC + caller gate: dryRun: false without callerOrchestrator throws BULK_CALLER_REQUIRED (default-deny on the destructive path). When provided and not "system", every matched task must have createdBy or assignedTo equal to the caller, else the entire mutation throws RBAC_DENIED — no partial close. Use "system" to bypass RBAC for fleet-wide cleanup.
Why this matters: before PR-F, cron-spam tasks could only be listed (with excludeAutoGenerated) but not closed in bulk. Pi's queue accumulated 152 cron-spawned tasks (audit section 13) that required individual complete_task calls to clear. bulk_complete_tasks drains the full backlog in two calls — one preview, one commit — with a verifiable audit trail via bulkRunId.