Background Tasks — Durable Outbox, Drain Worker, Survive Crashes

SkillProductivity

The AIDE pattern for long-running, durable background tasks, a JSONL outbox on disk that survives daemon restarts, a drain worker that replays idempotently on reconnect, jittered backoff for retries, and a UI badge for queue depth. Use when implementing Claude Code's Routines / Desktop scheduled tasks / cloud-async tasks, when wiring "kick off a long task and check back later", when reviewing durability of any in-flight async work, or when the user asks "what happens if my laptop dies mid-task?".

Use Background Tasks — Durable Outbox, Drain Worker, Survive Crashes in Claude, ChatGPT or Ahel Desktop

Free. Sign in, add Background Tasks — Durable Outbox, Drain Worker, Survive Crashes and connect your AI. About a minute.

Also: Claude Code · Cursor · Codex

Then ask your AI: use the Background Tasks skill

Details

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Add Ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Background Tasks — Durable Outbox, Drain Worker, Survive CrashesStart free

What this skill tells your AI

The instructions your AI receives, as published by anonymousnomad/covert-coder in skills/packs/aide-background-tasks/SKILL.md and read by Ahel’s review.

Born 2026-08-31 from the wiring audit. AIDE has no way to "kick off a long task and come back later". A git commit that takes 30 minutes, a claude-style "review every PR over the weekend" loop, a download 50GB of model weights — all of these need to survive daemon restarts, machine sleeps, and operator distraction. Cursor, Claude Code, Copilot all ship this. AIDE doesn't. This skill IS the wire-in.

Why this matters

  • Real workflows are long: training, downloading, code review, scheduled cleanup. None of these should block the user.
  • Crashes happen: machine sleep, power loss, daemon restart. Anything in-memory is lost. Anything in the outbox survives.
  • No-Brick-Wall: the user can close the laptop, come back tomorrow, the task is still running or has a result.
  • Cross-device: paired with the Telegram bridge, the user starts a task on the desktop, gets a Telegram ping when it's done.

The AIDE background-task contract (4 hard rules)

  1. A task is an idempotent JSONL entry in <workspace>/.aide/outbox.jsonl. Each entry has {idempotency_key, kind, payload, state, created_at, attempts, last_error, scheduled_for}. Append-only.
  2. The drain worker owns execution. It reads pending entries, executes them, writes the result back to the same entry. The worker survives crashes via kill -9 recovery: on restart, it reads the outbox, finds state === 'pending' or state === 'in_flight', replays them (idempotency key prevents double-execution).
  3. All outbound calls carry the idempotency key. The same key + same payload = no double work. The same key + different payload = hash compare, the newer wins, the older is dropped.
  4. Jittered backoff on failure. 1s, 2s, 4s, 8s, ..., capped at 5min. After 10 attempts, the entry moves to state: 'failed' and surfaces in the UI.

Files to touch (when wiring)

FileChange
node/src/services/outbox.mjsNEW: Outbox class with enqueue, claim, complete, fail, replay, list, purge.
node/src/routes/tasks.tsEXTEND: POST /api/tasks/enqueue, GET /api/tasks/outbox, GET /api/tasks/outbox/:id, POST /api/tasks/outbox/:id/cancel, GET /api/tasks/outbox/stats.
node/src/services/drain-worker.mjsNEW: the worker loop. Reads the outbox every 5s, claims pending entries, dispatches by kind, writes the result.
common/contracts/tasks.tsEXTEND: the 5 request/response zod schemas.
node/src/server.ts (or the routes aggregator)Wire the new routes. Start the drain worker on boot.
tests/arch/background-tasks.test.tsNEW: 5 tests (enqueue/claim/complete, replay after kill, backoff, dedup).
scripts/aide-bundle.cjs(optional) bundle task list CLI.
browser/src/...(optional) outbox badge in the status bar.

The contract (zod-strict)

// in common/contracts/tasks.ts

export const OutboxEntryKind = z.enum([
  'cloud_turn',         // long model call (e.g. opus-4.6 review)
  'download',           // HF model / file / repo download
  'training_run',       // full training job
  'agent_session',      // a long agent loop (e.g. multi-hour migration)
  'hook',               // a deferred hook call
  'desktop_action',     // a desktop control action (with grant)
  'cleanup',            // garbage collection, index rebuild
  'custom'              // user-defined
]);


## The outbox service

```js
// node/src/services/outbox.mjs
import { promises as fs } from 'node:fs';
import { randomUUID } from 'node:crypto';
import path from 'node:path';

export class Outbox {
  constructor({ workspace, file = '.aide/outbox.jsonl' }) {
    this.workspace = workspace;
    this.file = path.join(workspace, file);
  }

  // Append a new entry. Idempotency: if the same key exists in the file
  // with a hash-equal payload, return the existing entry (dedup).
  async enqueue({ kind, payload, scheduled_for, idempotency_key }) {
    const key = idempotency_key || randomUUID();
    const existing = await this.findByKey(key);
    if (existing && JSON.stringify(existing.payload) === JSON.stringify(payload)) {
      return existing;  // dedup
    }
    const entry = {
      idempotency_key: key,
      kind,
      payload,
      state: 'pending',
      created_at: Date.now(),
      scheduled_for: scheduled_for || null,
      attempts: 0,
      last_error: null,
      result: null,
      started_at: null,
      ended_at: null
    };
    await fs.appendFile(this.file, JSON.stringify(entry) + '\n', 'utf8');
    return entry;
  }

  // Claim: atomically read + mark a pending entry as in_flight.
  // Returns the claimed entry or null if none pending.
  async claim() {
    const entries = await this.readAll();
    const pending = entries.find(e => e.state === 'pending'
      && (e.scheduled_for === null || e.scheduled_for <= Date.now()));
    if (!pending) return null;
    pending.state = 'in_flight';
    pending.started_at = Date.now();
    pending.attempts += 1;
    await this.writeAll(entries);
    return pending;
  }

  async complete(idempotency_key, result) {
    const entries = await this.readAll();
    const e = entries.find(x => x.idempotency_key === idempotency_key);
    if (!e) return null;
    e.state = 'completed';
    e.result = result;
    e.ended_at = Date.now();
    await this.writeAll(entries);
    return e;
  }

  async fail(idempotency_key, error) {
    const entries = await this.readAll();
    const e = entries.find(x => x.idempotency_key === idempotency_key);
    if (!e) return null;
    e.last_error = String(error?.message ?? error);
    if (e.attempts >= 10) e.state = 'failed';
    else e.state = 'pending';  // retry
    e.ended_at = null;  // retry window open
    await this.writeAll(entries);
    return e;
  }

  async replay() {
    // On daemon restart: any in_flight entries are orphans (their worker died)
    // Move them back to pending for re-claim
    const entries = await this.readAll();
    for (const e of entries) {
      if (e.state === 'in_flight') {
        e.state = 'pending';
        e.started_at = null;
        // idempotency key prevents double-execution
      }
    }
    await this.writeAll(entries);
  }

  async list(filter = {}) {
    return (await this.readAll()).filter(e => {
      if (filter.state && e.state !== filter.state) return false;
      if (filter.kind && e.kind !== filter.kind) return false;
      return true;
    });
  }

  async stats() {
    const all = await this.readAll();
    const counts = { pending: 0, in_flight: 0, completed: 0, failed: 0, total: all.length };
    for (const e of all) counts[e.state] += 1;
    return counts;
  }
}

The drain worker

// node/src/services/drain-worker.mjs
import { setTimeout as sleep } from 'node:timers/promises';

export function startDrainWorker({ outbox, dispatch, intervalMs = 5000 }) {
  let stopped = false;
  let timer = null;
  async function tick() {
    if (stopped) return;
    try {
      const entry = await outbox.claim();
      if (!entry) {

## Threat matrix (the tests must cover these)

| Threat | Test | Pass criterion |
|---|---|---|
| Crashed worker duplicates work | Enqueue, claim, then kill the worker, restart | outbox.replay() moves the entry back to pending; next claim runs the same key (idempotency hash matches) — no double execution |
| Backoff caps at 5 min | Fail 10 times in a row | entry state === 'failed' after attempt 10; no further retries |
| Different payload with same key | Enqueue key=K with payload P1, then key=K with payload P2 | newer wins (P2 stored, P1 dropped) OR rejected (the spec must pick one — recommend REJECT to surface the bug) |
| Disk full | Try to enqueue with no disk space | outbox throws OutboxError with code DISK_FULL; user sees a clear message |
| Concurrent workers (multi-process) | Two daemons share the outbox file | one wins the claim, the other gets null (atomic append + read is racy; for true multi-process use file locking via `proper-lockfile` or move to sqlite) |
| Outbox grows unbounded | Enqueue 10K entries | rotation policy: every 1K completed, archive to `.aide/outbox.archive.jsonl`; UI shows archive count |

## Existing assets this skill USES

- `harness/cipher-state.mjs` → emit `outbox_enqueued`, `outbox_claimed`, `outbox_completed`, `outbox_failed` events
- `node/src/services/tasks.mjs` (existing) → the `TaskManager` for in-memory tasks; the outbox is its durable backend
- `node/src/services/telegram.mjs` (existing) → the Telegram bridge can subscribe to outbox events and notify the user
- `harness/veritas.mjs` → `evaluateVeritas` for scoring the result of long-running jobs

## Pitfalls

- **Do NOT store the outbox in memory.** Survive crashes = store on disk.
- **Do NOT use SQLite without a migration plan.** AIDE ships Node stdlib only. JSONL is the right primitive for now.
- **Do NOT block the request handler on enqueue.** `enqueue` is an append + return; the drain worker does the work async.
- **Do NOT skip the idempotency key.** Two enqueues with the same key + same payload = one execution. No key, no dedup. No dedup, no durability guarantee.
- **Do NOT use random backoff without jitter.** 100 workers retrying at the exact same second cause a thundering herd. Jitter (`Math.random() * 1000`) spreads them.
- **Do NOT let the outbox grow forever.** Rotate completed entries to an archive file. Keep the live file under 10MB.

## The rollout (2 PRs)

### PR A — Outbox service + 5 routes
- Add the outbox service to `node/src/services/outbox.mjs`
- Add the 5 zod schemas to `common/contracts/tasks.ts`
- Regenerate `common/openapi.json`
- Add the 5 routes
- 5 arch tests
- Commit: `feat(tasks): durable outbox for background tasks (PR A of aide-background-tasks)`

### PR B — Drain worker + boot wiring
- Add the drain worker to `node/src/services/drain-worker.mjs`
- Wire it on boot in the daemon
- Add the 8 dispatch handlers (one per kind)
- 2 more arch tests (replay, backoff)
- Commit: `feat(tasks): drain worker with idempotent replay (PR B of aide-background-tasks)`

## References

- `aide-resilience-orchestrator` R4 (durable outbox, the original spec)
- `aide-telegram-bridge-pattern` (cross-device notification when tasks complete)
- `aide-engine-lifecycle-doctrine` (operator owns lifecycle; the outbox survives even if the engine dies)
- `process-hygiene-sop` P7 (one model at a time, but the outbox is engine-independent)
- Claude Code Routines / Desktop scheduled tasks, Cursor Cloud Agents, Copilot background tasks — the rival patterns

        timer = setTimeout(tick, intervalMs);
        return;
      }
      try {
        const result = await dispatch(entry);  // dispatches by entry.kind
        await outbox.complete(entry.idempotency_key, result);
      } catch (error) {
        const backoff = Math.min(300_000, 1000 * 2 ** entry.attempts);  // 1s, 2s, 4s, ..., 5min
        await outbox.fail(entry.idempotency_key, error);
        timer = setTimeout(tick, backoff + Math.random() * 1000);  // jitter
        return;
      }
    } catch (e) {
      // Worker itself errored (e.g. outbox IO). Sleep and retry.
      console.error('drain-worker tick error', e);
    }
    timer = setTimeout(tick, intervalMs);
  }
  // Replay on boot: any in_flight entries go back to pending
  outbox.replay().then(tick);
  return {
    stop: () => { stopped = true; if (timer) clearTimeout(timer); }
  };
}

// Dispatcher: maps entry.kind -> async function
function makeDispatcher({ chatFn, modelManager, fileWriter, ... }) {
  return async function dispatch(entry) {
    switch (entry.kind) {
      case 'cloud_turn':    return await chatFn(entry.payload.messages, entry.payload.opts);
      case 'download':      return await modelManager.download(entry.payload.url, entry.payload.dest);
      case 'training_run':  return await runTraining(entry.payload);
      case 'agent_session': return await runAgentSession(entry.payload);
      case 'hook':          return await runHook(entry.payload);
      case 'desktop_action':return await runDesktopAction(entry.payload);
      case 'cleanup':       return await runCleanup(entry.payload);
      case 'custom':        return await runCustom(entry.payload);
      default: throw new Error(`unknown outbox kind: ${entry.kind}`);
    }
  };
}

export const OutboxEntryState = z.enum([ 'pending', // not yet claimed 'in_flight', // claimed by a worker 'completed', // done, result stored 'failed', // exceeded retry cap 'cancelled' // user cancelled ]);

export const OutboxEntry = z.object({ idempotency_key: z.string().uuid(), kind: OutboxEntryKind, payload: z.record(z.string(), z.unknown()), state: OutboxEntryState, created_at: z.number().int(), scheduled_for: z.number().int().nullable(), attempts: z.number().int().gte(0).default(0), last_error: z.string().nullable().optional(), result: z.record(z.string(), z.unknown()).nullable().optional(), started_at: z.number().int().nullable().optional(), ended_at: z.number().int().nullable().optional() }).strict();

export const OutboxEnqueueRequest = z.object({ kind: OutboxEntryKind, payload: z.record(z.string(), z.unknown()), scheduled_for: z.number().int().optional(), idempotency_key: z.string().uuid().optional() // generated if absent }).strict();

export const OutboxStats = z.object({ pending: z.number().int().gte(0), in_flight: z.number().int().gte(0), completed: z.number().int().gte(0), failed: z.number().int().gte(0), total: z.number().int().gte(0) }).strict();

Signals

GitHub stars
43
Forks
14
Last commit
Oct 2026
Advanced
Item type
skill
Key
aide-background-tasks
Source
github.com/anonymousnomad/covert-coder