Background Tasks

Friday is single-threaded in the way that matters. One ACP session, one prompt at a time. Everything queues behind everything else.

If you ask Friday to research something that takes five minutes, you're locked out for five minutes. No progress indicator, no way to check, no notification when it finishes. Send a message during that window and it queues silently.

Scheduled tasks make it worse. They fire on the heartbeat, block the same session, and share the context window. A chatty health check pushes earlier conversation out of the window. The model forgets what we were talking about.

The constraint isn't CPU or memory. It's attention.

The fix sounds simple: run things in the background. In practice, that means a job queue, a state machine, pause/resume, per-task logging, crash recovery, archival, and migrating the existing scheduler to use all of it. Seven phases. Three hundred and fifty-six tests. One SQLite table.

The Funnel

Three input paths – Telegram messages, the scheduler heartbeat, the HTTP injection endpoint – all pouring into one ACP session. One turn at a time. If a scheduled task fires while I'm mid-conversation, it waits. If I send a message while a task is running, I wait.

For fast tasks, this is fine. But a five-minute research prompt means five minutes of nothing.

Scheduled tasks have an extra problem. They run through the heartbeat's prompt() call, which fires and returns before the model has even started. The heartbeat has no way to know when a task actually finishes. The watchdog from the previous chapter fixed crash recovery, but the core limitation remains: everything competes for the same session.

One Table, Many States

We need a way to track background tasks – what's queued, what's running, what's paused waiting for a question. It turns out one SQLite table can handle all of it. Every state transition is a database write. Slower than an in-memory queue, but the state survives crashes and a future web UI can query it without new infrastructure.

Let's look at the schema:

CREATE TABLE IF NOT EXISTS background_tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    prompt TEXT NOT NULL,
    project_context TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'queued',
    acp_session_id TEXT,
    waiting_for TEXT,
    waiting_request_id TEXT,
    waiting_answer TEXT,
    result TEXT,
    archived_summary TEXT,
    log_file_path TEXT,
    heartbeat_at TEXT,
    created_at TEXT NOT NULL,
    started_at TEXT,
    completed_at TEXT
);

This is from src/db/database.ts

The status column drives everything. There are eight possible values:

export type BackgroundTaskStatus =
    | 'queued'
    | 'running'
    | 'paused'
    | 'completed'
    | 'failed'
    | 'cancelled'
    | 'interrupted'
    | 'archived';

This is from src/db/background-tasks.ts

The state machine flows like this: queued → running → paused | completed | failed | cancelled | interrupted → archived. A running task completes, fails, gets cancelled, or gets interrupted by a crash. A paused task resumes back to running when someone answers its question.

Completed, failed, and cancelled tasks can eventually be archived – an LLM summary replaces the full result text, and the log file gets cleaned up.

interrupted is for crash recovery. If the process restarts and finds a task that was running with a stale heartbeat, we can mark it interrupted and re-queue it. No intervention needed.

One Session Per Task

We give each background task its own ACP session, scoped to its own project directory. This is the big departure from the funnel. The main session stays free for conversation.

A factory function creates them:

const runner = new BackgroundTaskRunner(
    bgTasks,
    (projectCwd, taskId) =>
        new Session(state, {
            cwd: `background::${taskId}`,
            executionCwd: projectCwd,
            model: 'claude-sonnet-4-6',
            effort: 'medium',
            permissionMode: 'autonomous',
            writeLog: makeTaskLogger(taskId),
        }),
    config
);

This is from src/index.ts

It turns out there's a collision hiding here. Session stores its ACP session ID in the state table, keyed by acp_session_id::<cwd>. Two background tasks targeting the same project directory would write to the same key, overwriting each other's session IDs.

The fix: pass a synthetic key – background::${taskId} – for state-table scoping, while the real project path goes in executionCwd. We also store the actual ACP session ID on the background_tasks row for debugging.

We cap concurrency at two. Two background sessions plus the main session is three active streams – enough to be useful without burning through the subscription's concurrent session allowance. It could go higher, but keeping it low was deliberate.

The Runner's Tick

We have the runner poll every 30 seconds, same interval as the heartbeat. Each tick does three things, and the order matters:

#tick(): void {
    this.#checkGates();
    this.#updateHeartbeats();
    this.#dispatchQueued();
}

This is from src/background/runner.ts

We check gates before dispatch, because a task that just paused shouldn't count toward the concurrency cap. If we dispatched first and checked gates second, we'd skip a queued task because we counted a just-paused one as still running.

#checkGates() polls session.gate.getPending() for each active session. If a question is pending, the runner stores it in the database, marks the task paused, and sends a Telegram notification. You see something like "Background task is asking: Do you want to proceed with the destructive migration?" and can answer right from Telegram.

#updateHeartbeats() writes a fresh timestamp for every running task. This is how crash recovery detects stale tasks – if the heartbeat is older than two tick intervals, the task is assumed dead and gets re-queued.

#dispatchQueued() counts running tasks, figures out available slots, and launches queued tasks up to the cap.

Pause Without New Machinery

When a background task calls AskUserQuestion, the existing PermissionGate mechanism can handle it for free. The gate's Promise blocks the ACP stream in place – the task just stops. The runner can detect this on its next tick, store the question, and send a notification.

Here's the neat part. When you answer, we write it to waiting_answer in the database before resolving the gate. If Friday crashes between storing the answer and resolving the gate, the task gets re-queued on startup. When the re-launched session re-asks the same question, the runner finds the stored answer and auto-resolves silently. You never have to answer twice.

Redirect vs. Answer

We need two ways to interact with a running task. "Answer" resolves a pending question and lets the task continue on its current path. "Redirect" is more aggressive – it cancels the running ACP stream and re-prompts the same session with a new instruction.

Why both? A redirect can correct a task that's going the wrong direction without losing the session context. "Actually, use ALTER instead of dropping the column" changes course without starting over. It only works while the task is still running – once it's completed, the session is gone.

The Hard Parts

I didn't build a Telegram command for managing background tasks. Instead, answering and redirecting go through Claude Code project skills that talk to the runner via localhost HTTP routes:

routes.post('/background/:id/respond', async (c) => {
    const taskId = parseInt(c.req.param('id'), 10);
    const body = await c.req.json<{ answers?: Record<string, string> }>();
    const resolved = runner.respondToTask(taskId, body.answers);
    if (!resolved) {
        return c.json({ error: 'task not found or not waiting' }, 404);
    }
    return c.json({ status: 'ok' });
});

routes.post('/background/:id/redirect', async (c) => {
    const taskId = parseInt(c.req.param('id'), 10);
    const body = await c.req.json<{ message?: string }>();
    const result = await runner.redirectTask(taskId, body.message);
    if (result.error) {
        return c.json({ error: result.error }, result.code);
    }
    return c.json({ status: 'ok' });
});

This is from src/routes/background.ts

The skills run inside the main ACP session, so Claude can do the natural-language matching. "Cancel the one about fish" works because the main session can list the tasks, figure out which one is about fish, and hit the right endpoint. No IDs, no special syntax.

Cleaning Up Old Tasks

Tasks older than 14 days can be archived. An LLM summary replaces the full result text and the log file gets cleaned up. The summary has to come from somewhere, and using the main session would be wrong – it might be mid-conversation.

So the archiver can spin up a throwaway session with a compact prompt ("Summarise the following task result in at most 200 words"), get the summary, and shut down. One startup pass plus a daily interval keeps the database from growing forever.

It gets its own session factory with cwd: 'archiver::summary' – the same synthetic key approach from the runner, applied to a different context.

The Migration

Phase seven was the hardest part. The heartbeat had to stop calling session.prompt() directly and start creating background task rows instead. Everything the scheduler already did – detecting late tasks, advancing schedules, deactivating one-shot tasks – needed to keep working exactly as before.

The trick is onTaskComplete. When the heartbeat creates a background task row, it registers a callback. When the runner finishes the task, it fires that callback, and the heartbeat does its usual bookkeeping.

It doesn't know or care how the task ran. It just knows when it's done.

Conclusion

The funnel is gone. The main ACP session handles conversation. Background tasks run in their own sessions, up to two at a time, with their own project contexts and log files. Scheduled tasks go through the same runner, getting logging and notifications for free.

From your side, it's conversational. "Run this in the background" creates a task. "What's running?" lists them. "Tell the one about the database migration that yes, drop the column" answers a paused task. "Actually, do it differently – use ALTER instead" redirects a running one.

The notifier sends Telegram messages at every interesting transition: paused (with the question), completed (with a summary), failed (with the error), interrupted and re-queued. You don't have to poll. You get a message when something needs attention and another when it's done.

If the process crashes, the runner detects stale tasks on restart and re-queues them. If it already stored an answer, it replays it silently. If the process stays up, tasks churn through the queue two at a time, each one isolated in its own session with its own context window.

It's the biggest feature so far. And the one that finally made Friday feel like it could hold more than one thought at a time.