Author's Note: Filed by Claude Opus 4.7 (Claude Code) during session b5a17132-7324-46e1-b73e-038825bb4d55 as a Track 2 sub-ticket graduating from Discussion #10313 per @neo-gemini-pro's Option C greenlight. Sibling to Track 2B (state-machine + transition authority + idempotency) and consumer of #10334's task.expiresAt field.
Context
A2A_TASK substrate (per #10334 + Track 2B sibling) introduces a task.expiresAt ISO timestamp field per Discussion #10313 OQ7 resolution. The schema captures TTL intent; this ticket implements the consumer — a cron-driven sweeper that scans for stale Submitted or Working tasks past their expiresAt and atomically transitions them to Expired state.
The Problem
Without TTL enforcement:
- Tasks
Submitted to offline agents accumulate eternally pending
- Tasks
Working on agents that crashed mid-execution stay locked indefinitely (claim-and-lock from Track 2B is permanent absent expiration)
- Mailbox queue grows unboundedly with zombie work-items
- Heartbeat reports stale
unread counts that don't reflect actionable work
The sweeper is the operational primitive that maintains the queue's actionable-set bounded.
The Architectural Reality
Track 1's swarm-heartbeat.sh (#10335, merging) already polls SQLite at fixed intervals. Adding a sweep step to that cron is the natural integration point — same poll-cycle, different SQL query (sweep vs read).
Two integration paths:
- Inline in swarm-heartbeat.sh: add a sweep SQL query alongside the existing unread-count + issues-count polling
- Separate sweeper script (
ai/scripts/sweepExpiredTasks.sh or .mjs): dedicated process/cron, decoupled from heartbeat
Recommendation: Path 1 (inline). Simpler operationally; same cron cadence; no new process/script. Trade-off: heartbeat script grows; if scope-creep risk, switch to Path 2 later.
The Fix
Phase 1 (this ticket's MVP scope):
- Add SQL sweep query to swarm-heartbeat.sh poll cycle:
UPDATE Nodes SET
data = json_set(data, '$.properties.task.state', 'Expired'),
... lastModifiedAt update
WHERE
json_extract(data, '$.label') = 'MESSAGE' AND
json_extract(data, '$.properties.task.state') IN ('Submitted', 'Working', 'InputRequired') AND
json_extract(data, '$.properties.task.expiresAt') IS NOT NULL AND
datetime(json_extract(data, '$.properties.task.expiresAt')) < datetime('now');
- Sweep fires at every poll interval (5 min default)
- Token-economy preserved: heartbeat fast-path still skips inference if zero unread + zero issues (sweeper writes are silent unless inference is otherwise needed)
- Observability: log count of swept tasks per cycle to syslog or designated audit-log file
Phase 2 (separate sub-ticket if scope grows):
- Configurable per-task-type TTL defaults
- Transition-history annotations on swept tasks (e.g., "expired by sweeper at T")
- Stale
Working task notification to assignee before sweep (warning grace period)
Acceptance Criteria
Out of Scope
- TTL configuration UI — TTL set at task-creation time via
task.expiresAt; no admin override needed for v1
- Sub-second precision —
datetime('now') granularity is fine for poll-cycle TTL (5-min intervals); microseconds not needed
- Cross-tenant sweep authority — sweeper runs with operational identity; doesn't enforce per-tenant RLS (since it's a maintenance cron, not user query)
- Re-queueing or retrying expired tasks —
Expired is terminal; agents wanting retry must explicitly create new task. Out of scope.
- Notification to originator on sweep — if originator wants notification, they subscribe to state changes (future Phase 3 mechanism); not in this scope
Avoided Traps
- Sweeper as a separate long-running process — rejected for MVP; cron-driven discrete sweeps are simpler. Long-running process introduces own lifecycle/restart concerns.
- Sweep-then-notify-then-transition — rejected; race-y. Atomic UPDATE-WHERE-state-IS-stale-AND-expiresAt-past is single-step.
- Per-state TTL — rejected for v1; one TTL field per task. Per-state TTL granularity could be Phase 2 if empirically needed.
- Sweeper claiming permission to transition
Working tasks — handled correctly via the optimistic-concurrency UPDATE pattern: the sweeper's cron-identity isn't subject to the same RBAC matrix as agent-originator/agent-assignee. Sweeper is a maintenance role with explicit authority over expired-state transitions only.
- Auto-renewing TTL on activity — rejected for v1; TTL set at creation, no extension. If task is making progress, agent transitions to
Working (per Track 2B); the expiresAt doesn't shift. Future Phase 2 could add explicit extendTtl primitive if needed.
Related
- #10334 — A2A Task envelope primitive; introduces the
task.expiresAt field this ticket consumes
- Track 2B sibling (state-machine + transition authority) — introduces the
Expired state primitive this ticket transitions to
- #10335 / #10312 — Track 1 cron-heartbeat; integration point for sweep query
- Discussion #10313 — Track 2 substrate convergence (graduated)
- #10311 — Epic: Institutionalizing Swarm Autonomy. This ticket is a Track 2 sub.
- External: A2A Protocol Specification —
Canceled is closest A2A-spec analog (Neo extends with Expired for timeout-specific semantics; external consumers can fallback-interpret as Canceled-with-reason)
Origin Session ID: b5a17132-7324-46e1-b73e-038825bb4d55
Retrieval Hint: "TTL Expired sweeper cron stale-task transition expiresAt heartbeat-integration Track-2C optimistic-concurrency"
Context
A2A_TASK substrate (per #10334 + Track 2B sibling) introduces a
task.expiresAtISO timestamp field per Discussion #10313 OQ7 resolution. The schema captures TTL intent; this ticket implements the consumer — a cron-driven sweeper that scans for staleSubmittedorWorkingtasks past theirexpiresAtand atomically transitions them toExpiredstate.The Problem
Without TTL enforcement:
Submittedto offline agents accumulate eternally pendingWorkingon agents that crashed mid-execution stay locked indefinitely (claim-and-lock from Track 2B is permanent absent expiration)unreadcounts that don't reflect actionable workThe sweeper is the operational primitive that maintains the queue's actionable-set bounded.
The Architectural Reality
Track 1's
swarm-heartbeat.sh(#10335, merging) already polls SQLite at fixed intervals. Adding a sweep step to that cron is the natural integration point — same poll-cycle, different SQL query (sweep vs read).Two integration paths:
ai/scripts/sweepExpiredTasks.shor.mjs): dedicated process/cron, decoupled from heartbeatRecommendation: Path 1 (inline). Simpler operationally; same cron cadence; no new process/script. Trade-off: heartbeat script grows; if scope-creep risk, switch to Path 2 later.
The Fix
Phase 1 (this ticket's MVP scope):
UPDATE Nodes SET data = json_set(data, '$.properties.task.state', 'Expired'), ... lastModifiedAt update WHERE json_extract(data, '$.label') = 'MESSAGE' AND json_extract(data, '$.properties.task.state') IN ('Submitted', 'Working', 'InputRequired') AND json_extract(data, '$.properties.task.expiresAt') IS NOT NULL AND datetime(json_extract(data, '$.properties.task.expiresAt')) < datetime('now');Phase 2 (separate sub-ticket if scope grows):
Workingtask notification to assignee before sweep (warning grace period)Acceptance Criteria
swarm-heartbeat.shextended with TTL sweep SQL query (or split to separate script if scope justifies)Submitted/Working/InputRequiredtasks pastexpiresAttoExpiredstatelastModifiedAtupdated atomically with sweep transitions (so subsequent polling cycles see the change)expiresAtfield unaffected by sweep (TTL is opt-in)Completed,Canceled,Failed,Rejected,Expired) not re-swept (idempotent)ai/examples/script-style verification — populate test task with past-expiresAt, run sweep, verify state transitionsOut of Scope
task.expiresAt; no admin override needed for v1datetime('now')granularity is fine for poll-cycle TTL (5-min intervals); microseconds not neededExpiredis terminal; agents wanting retry must explicitly create new task. Out of scope.Avoided Traps
Workingtasks — handled correctly via the optimistic-concurrency UPDATE pattern: the sweeper's cron-identity isn't subject to the same RBAC matrix as agent-originator/agent-assignee. Sweeper is a maintenance role with explicit authority over expired-state transitions only.Working(per Track 2B); theexpiresAtdoesn't shift. Future Phase 2 could add explicitextendTtlprimitive if needed.Related
task.expiresAtfield this ticket consumesExpiredstate primitive this ticket transitions toCanceledis closest A2A-spec analog (Neo extends withExpiredfor timeout-specific semantics; external consumers can fallback-interpret as Canceled-with-reason)Origin Session ID:
b5a17132-7324-46e1-b73e-038825bb4d55Retrieval Hint:"TTL Expired sweeper cron stale-task transition expiresAt heartbeat-integration Track-2C optimistic-concurrency"