---
id: 10339
title: 'Track 2C: TTL/Expired sweeper — cron-driven stale-task transition to Expired state'
state: CLOSED
labels:
  - enhancement
  - ai
  - architecture
assignees:
  - neo-opus-ada
createdAt: '2026-04-25T20:05:39Z'
updatedAt: '2026-06-07T17:21:26Z'
githubUrl: 'https://github.com/neomjs/neo/issues/10339'
author: neo-opus-ada
commentsCount: 1
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-04-26T01:39:38Z'
---
# Track 2C: TTL/Expired sweeper — cron-driven stale-task transition to Expired state

> **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:
1. **Inline in swarm-heartbeat.sh:** add a sweep SQL query alongside the existing unread-count + issues-count polling
2. **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):**

1. Add SQL sweep query to swarm-heartbeat.sh poll cycle:
   ```sql
   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');
   ```
2. Sweep fires at every poll interval (5 min default)
3. Token-economy preserved: heartbeat fast-path still skips inference if zero unread + zero issues (sweeper writes are silent unless inference is otherwise needed)
4. 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

- [ ] `swarm-heartbeat.sh` extended with TTL sweep SQL query (or split to separate script if scope justifies)
- [ ] Sweep correctly transitions `Submitted`/`Working`/`InputRequired` tasks past `expiresAt` to `Expired` state
- [ ] `lastModifiedAt` updated atomically with sweep transitions (so subsequent polling cycles see the change)
- [ ] Tasks WITHOUT `expiresAt` field unaffected by sweep (TTL is opt-in)
- [ ] Tasks already in terminal states (`Completed`, `Canceled`, `Failed`, `Rejected`, `Expired`) not re-swept (idempotent)
- [ ] Observability: per-cycle log of swept-count (zero-default to avoid noise)
- [ ] Test: integration test using `ai/examples/` script-style verification — populate test task with past-`expiresAt`, run sweep, verify state transitions
- [ ] No regression on heartbeat token-economy fast-path (if no actionable state, no inject)
- [ ] Update OpenAPI schema if Sweeper exposes any new tool surface (likely none — sweeper is internal cron, no MCP tool)

## 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](https://a2a-protocol.org/latest/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"`

## Timeline

- 2026-04-25T20:05:40Z @neo-opus-ada added the `enhancement` label
- 2026-04-25T20:05:40Z @neo-opus-ada added the `ai` label
- 2026-04-25T20:05:40Z @neo-opus-ada added the `architecture` label
- 2026-04-25T20:19:29Z @neo-opus-ada cross-referenced by PR #10340
- 2026-04-25T22:51:15Z @neo-opus-ada cross-referenced by PR #10342
### @neo-opus-ada - 2026-04-25T22:59:33Z

## 🌅 Sunset Protocol Handover — Session b5a17132 ending at context cap

**Author:** Claude Opus 4.7 (Claude Code), session `b5a17132-7324-46e1-b73e-038825bb4d55` (final Sandman handoff turn).
**Status at session-end:** Unblocked. Dependencies merged tonight: #10334 envelope (#10340), Track 2B state-machine (#10342) → Track 2C is the remaining piece for A2A_TASK substrate completion.

## Where this ticket sits

#10339 = TTL/Expired sweeper. Cron-driven. Gates on Track 2B's `Expired` state primitive (now landed via #10342 merge). Natural integration point: extend `swarm-heartbeat.sh` (#10335 merged earlier) with sweep SQL query, OR file separate sweeper script.

## Implementation guidance (per ticket body)

```sql
UPDATE Nodes SET 
  data = json_set(data, '$.properties.task.state', 'Expired',
    '$.properties.lastModifiedAt', datetime('now'))
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');
```

Recommendation: Path 1 (inline in swarm-heartbeat.sh). Same cron cadence; no new process. Trade-off documented in body — split to separate script if scope grows.

## Pickup signal

Either of us (Claude or Gemini). Phase 2 final substrate primitive. Estimated 1-2 hours including tests. Cross-family review when PR opens.

— @neo-opus-4-7 · Origin Session ID: `b5a17132-7324-46e1-b73e-038825bb4d55`


- 2026-04-26T00:11:56Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-04-26T00:38:45Z @neo-opus-ada cross-referenced by PR #10350
- 2026-04-26T00:39:49Z @neo-opus-ada cross-referenced by #10351
- 2026-04-26T00:46:07Z @neo-opus-ada cross-referenced by PR #10352
- 2026-04-26T01:39:38Z @tobiu referenced in commit `bc5f50e` - "feat(mailbox): TTL/Expired sweeper for stale A2A tasks (#10339) (#10350)

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-04-26T01:39:39Z @tobiu closed this issue
- 2026-05-01T18:36:49Z @neo-opus-ada cross-referenced by #10595
- 2026-05-01T18:41:58Z @neo-opus-ada cross-referenced by PR #10597
- 2026-05-03T21:45:14Z @neo-opus-ada cross-referenced by #10650
- 2026-05-11T00:31:51Z @neo-opus-ada cross-referenced by PR #11170
- 2026-05-24T21:08:52Z @neo-opus-ada cross-referenced by PR #11920

