Skip to content

feat(autonomy): durable task comments and the enrichment loop - #639

Draft
jamiepine wants to merge 2 commits into
mainfrom
jamiepine/autonomy-enrichment-loop
Draft

feat(autonomy): durable task comments and the enrichment loop#639
jamiepine wants to merge 2 commits into
mainfrom
jamiepine/autonomy-enrichment-loop

Conversation

@jamiepine

@jamiepine jamiepine commented Aug 10, 2026

Copy link
Copy Markdown
Member

Builds on #631 (autonomy channel/goals/wakes/run-history shell). The channel had a survey and a run summary but nothing to write findings into — and no task tools registered at all. The enrichment loop in autonomy.md was prose. This lands the code.

Task comments

New task_comments table, append-only. seq INTEGER PRIMARY KEY AUTOINCREMENT is the ordering key rather than created_at: two comments in the same millisecond would otherwise have no defined order and pagination cursors would skip or repeat rows. seq is also the cursor the list endpoint returns.

TaskStore::delete clears a task's comments in the same transaction as the task. ON DELETE CASCADE is declared too, but it only fires when the connection has PRAGMA foreign_keys on — a setting the store doesn't own.

GET/POST /tasks/{n}/comments, regenerated OpenAPI + TS client, and a comment thread on task detail in both task routes. A worker-linked comment renders a pill that fetches that worker's output on expand — the body stays the agent's summary, transcripts never inline.

Enrichment cadence

last_enriched_at on tasks, stamped in the same transaction as the comment that earned it, so a crash can't mark a task enriched without the finding that enriched it. User comments deliberately don't move it — being commented on is what pulls a task back to the front.

TaskStore::select_for_enrichment does the ordering in SQL: user-engaged since last enrichment, then never enriched, then stale, with last run's work excluded unless a user has since commented. It renders into the wake briefing as an Enrichment Queue with each entry's recent comments inlined, so the run opens on a list that already excludes what it just did.

max_tasks_per_run is enforced at the tool boundary now instead of being a request in the prompt:

// Spent per task, not per comment — a run can keep working something it started.
if let Some(budget) = &self.budget
    && !budget.try_touch(task_number)
{
    return Err(AddTaskCommentError(format!(
        "this run's allowance of {} task{} is spent — record what you have and call autonomy_complete",
        budget.max_tasks(), if budget.max_tasks() == 1 { "" } else { "s" }
    )));
}

Claiming

add_task_comment claims an unassigned task atomically before writing when claim_unowned is set. Listing claims nothing. observe gets no mutation tools registered at all, so it can't claim by any path.

UPDATE tasks SET assigned_agent_id = ?, updated_at = ...
WHERE task_number = ? AND assigned_agent_id IS NULL

claim_next_ready now takes assignment and in_progress in one guarded UPDATE and can pick up unowned ready work.

Worker continuity

Ready-task pickup appends the task's comments to the worker prompt — oldest first, 12 comments, 600 bytes each, 4000 total.

WorkerContextMode::Briefed does not exist in source. WorkerContextMode is a struct of history/memory/wiki_write; the enum in worker-briefing.md was never built. Comments are injected at ready-task pickup instead, which is the only place a worker is actually bound to a task and therefore the only place the briefing has comments to carry. Flagged rather than silently reinterpreted.

Goals

task_create takes goal_id through tool, store and API, resolving it against the goal store first so an unknown id is a tool error rather than a dangling link.

Autonomy gets goal_update with the status field removed from the schema, not just discouraged in prose. Each run then programmatically flags active goals whose linked tasks are all done:

pub const GOAL_READY_FOR_REVIEW_NOTE: &str = "All linked tasks complete — ready for your review.";

Prepended to the goal's notes, keeping the agent's own assessment beneath it. Idempotent, treats failed as work remaining, never touches status. Goals never auto-complete.

Quiet-while-active: rejected

Marked rejected in autonomy.md with the reasoning, not left on the phase list. Enrichment is most useful while the user is around to react to it, and the wake already carries the signal that matters — a user comment pulls the next run forward rather than pushing it away. A global "someone is talking, stand down" flag suppresses the run for reasons unrelated to the task it was going to work on.

Testing

23 new tests. Adversarial ones:

  • concurrent_claims_produce_one_winner / concurrent_first_comments_leave_one_winner — two agents race the same unowned task; exactly one wins, the loser gets a skip with nothing written
  • selection_excludes_work_from_the_previous_run — and that a user comment clears the exclusion
  • selection_orders_user_engaged_then_never_enriched_then_stale, selection_respects_assignment_and_claim_unowned
  • comment_rejects_another_agents_task — wrong-agent access
  • budget_caps_distinct_tasks_per_run
  • agent_comment_stamps_enrichment_and_user_comment_does_not
  • goals_are_flagged_for_review_only_when_every_linked_task_is_done — partial, zero-task and failed-task goals stay unflagged; re-running doesn't stack markers
  • deleting_a_task_removes_its_comments, create_rejects_an_unknown_goal_link

Two of these were flaky on first write and the second commit fixes the causes, one of which was a real bug:

  1. The user-engaged check used > against last_enriched_at. Both are millisecond-precision, so a user comment landing in the same millisecond as the preceding enrichment was silently dropped from selection. Now inclusive.
  2. Test fixtures moved from shared-cache in-memory SQLite to a file-backed temp DB. Shared-cache raises SQLITE_LOCKED on contention, which busy_timeout doesn't retry — the concurrency tests were flaking on an artifact no deployment can hit.

Validation state: cargo fmt --all --check, cargo check --all-targets, RUSTFLAGS=-Dwarnings cargo clippy --all-targets, cargo test --lib (1169 passed), just check-typegen (generated schema in sync), bunx tsc --noEmit and bun run build all pass. Migration safety passed — only new migration files. The final cargo test --tests --no-run step of gate-pr.sh was still running when I cut the PR; it passed on an earlier identical run of the gate.

🤖 Generated with Claude Code

Note

AI Summary: This PR implements the autonomy enrichment loop with durable task comments. Key additions: a new task_comments table with sequence-based ordering to prevent pagination issues, enrichment selection logic that prioritizes user-engaged tasks and excludes recent work, atomic task claiming for racing agents, goal review flagging when all linked tasks complete, and 23 new adversarial tests covering concurrency, budget enforcement, and access control. Two follow-up commits fix a millisecond-precision race condition in the user-engagement check and flaky concurrency tests by switching to file-backed SQLite. All validation checks pass (cargo fmt, clippy, 1169 lib tests, generated schema sync).

Written by Tembo for commit 1ac3b5ad.

The autonomy channel had a survey and a run summary but nothing to write
findings into, and no task tools registered at all — the enrichment loop
described in autonomy.md was prose, not code. This lands the missing half.

Task comments
- New `task_comments` table (append-only, `seq`-ordered so pagination is
  stable inside a millisecond), with author type/id, optional worker link,
  and metadata. `TaskStore::delete` clears comments in the same transaction
  rather than relying on a cascade that only fires with `PRAGMA foreign_keys`.
- `GET`/`POST /tasks/{n}/comments`, generated OpenAPI + TS client, and a
  comment thread on task detail in both task routes. Worker-linked comments
  render a pill that expands the worker's output on demand.

Enrichment cadence
- `last_enriched_at` on tasks, stamped in the same transaction as the comment
  that earned it. User comments deliberately do not move it.
- `TaskStore::select_for_enrichment` orders candidates in SQL: user-engaged
  since last enrichment, then never enriched, then stale — with last run's
  work excluded unless a user has since commented. Rendered as an Enrichment
  Queue in the wake briefing, comments inlined.
- `max_tasks_per_run` is now enforced at the tool boundary via a per-run
  budget instead of being a request in the prompt.

Claiming
- `add_task_comment` claims an unassigned task atomically before writing when
  `claim_unowned` is set; the loser of a race gets a clean skip. Listing
  claims nothing, and `observe` gets no mutation tools to claim with.
- `claim_next_ready` takes assignment and `in_progress` in one guarded UPDATE
  and can pick up unowned ready work.

Worker continuity
- Ready-task pickup appends the task's comments to the worker prompt, bounded
  per comment and in total. `WorkerContextMode::Briefed` does not exist in
  source; the injection landed where a worker is actually bound to a task.

Goals
- `task_create` takes `goal_id` through tool, store, and API, resolving it
  against the goal store first so an unknown id is an error, not a dangling
  link.
- Autonomy gets `goal_update` with the `status` field removed from the schema
  entirely, and each run programmatically flags active goals whose linked
  tasks are all done with a ready-for-review note. Goals never auto-complete.

Docs: autonomy.md and goals.md now describe what ships. "Quiet while active"
is marked rejected, with the reasoning, rather than left on the phase list.
… enrichment

The user-engaged check compared a comment's created_at strictly greater than
last_enriched_at. Both are millisecond-precision, so a user comment landing in
the same millisecond as the enrichment that preceded it was dropped from the
selection and the task fell out of the queue. Comparison is now inclusive:
erring toward one extra look is the safe direction, erring toward losing a
user's input is not.

Test fixtures move from a shared-cache in-memory database to a file-backed one
in a temp dir. Shared-cache SQLite raises SQLITE_LOCKED on contention, which
busy_timeout does not retry, so the concurrency tests were flaking on an
artifact no deployment can hit. The file DB is also what the instance store
actually runs on.

The concurrent-claim assertion was too narrow: the loser can lose at either
gate — reading the assignment the winner committed, or losing the guarded
UPDATE — and both correctly end in a skip with nothing written.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: baff2f43-9d79-492a-863e-4bd7367b2876

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant