Jobs and works
attacca_api autonomous work — queue a job and answer its questions, or plan a work into a task graph and let it through both approval gates.
A session is a conversation. When a node wants something done rather than discussed, it reaches
for a job or a work. Both run on ordinary sessions, so everything on
Sessions and turns — send_message, turn_events — works on them too.
| Job | Work | |
|---|---|---|
| What it is | One message handed to an agent that runs it to completion on its own session, reporting long results as artifacts. | A goal planned into a graph of tasks, each executed in its own git worktree and merged into a shared integration branch. |
| Starts | On creation — it comes back already processing. | Planning starts on creation, then it stops at two approval gates. |
| Scopes | jobs:read, jobs:write | works:read, works:write |
Jobs
| Tool | Behaviour |
|---|---|
list_jobs(filter: ZJobFilter) -> Vec<ZJob> | Newest first. ZJobFilter { project_id: Option, limit: Option<u32> }. |
get_job(job_id) -> ZJob | Read one. |
create_job(job: ZNewJob) -> ZJob | Queue and start. The first turn is enqueued before the call returns. |
update_job(job_id, update: ZJobUpdate) -> ZJob | Edit title, description or project_id; omitted fields are left alone. State cannot be set. |
delete_job(job_id) -> () | Delete it. |
ZNewJob field | Type | Notes |
|---|---|---|
message | String | What the agent is asked to do; also what the job is titled from. |
agent_id | Option<String> | Omit to run on the account's Main Agent. |
project_id | Option<String> | Omit for the default project. |
timezone | Option<String> | IANA name, e.g. Europe/Berlin — what the agent answers "what time is it" with. Omit for the deployment's own. |
planning | bool | Draft a plan and turn it into a work instead of doing the work here. |
plan_mode | bool | Investigate, hand back a plan and wait for approval before executing — all within this one job. |
data | Vec<Datum> | Attachments for the opening message. Text datums are folded into message; files land in the account's workspace. |
ZJob field | Type | Notes |
|---|---|---|
id, title | String | The title is generated in the background, so a job read back at once still carries Attacca's placeholder. |
description | Option<String> | |
state | ZJobState | Derived by Attacca from the job's session. |
project_id, agent_id | Option<String> | |
session_id | Option<String> | The session driving the job: send_message to it to answer a question, turn_events on it to watch. |
last_activity | Option<String> | A one-line "what the agent is doing right now". |
failure_reason | Option<String> | Set only when failed. |
planning | bool | A planning job. |
created_at, updated_at | Option<String> | RFC 3339. |
ZJobState | Meaning |
|---|---|
backlog | Queued. |
processing | Running. |
done | Finished. |
ambiguous | The agent asked a question and is waiting — answer with send_message to session_id. |
failed | Ended; see failure_reason. |
recurring | A recurring job. |
awaiting_plan | A plan_mode job has produced a plan and waits for it to be approved. |
Watch a job with turn_events on its session_id, or poll get_job:
use std::time::Duration;
use zyris_attacca::{AttaccaApi, AttaccaApiClient, ZJobState, ZNewJob};
async fn run_job(api: &AttaccaApiClient) -> zyris::Result<()> {
let job = api
.create_job(ZNewJob {
message: "Summarize this week's changelog".into(),
agent_id: None, // the account's Main Agent
project_id: None, // the default project
timezone: Some("Europe/Berlin".into()),
planning: false,
plan_mode: false,
data: vec![],
})
.await?;
loop {
tokio::time::sleep(Duration::from_secs(5)).await;
let job = api.get_job(job.id.clone()).await?;
match job.state {
ZJobState::Done => return Ok(()),
ZJobState::Failed => {
eprintln!("failed: {}", job.failure_reason.unwrap_or_default());
return Ok(());
}
ZJobState::Ambiguous => {
// The agent asked something; answer on the job's own session.
let session_id = job.session_id.expect("a running job has a session");
api.send_message(session_id, "Only the public changes.".into(), vec![]).await?;
}
_ => println!("{:?}: {}", job.state, job.last_activity.unwrap_or_default()),
}
}
}A real node would remember that it already answered rather than replying on every poll while the state catches up.
Works
A work pauses twice for approval, which is why creating one is not enough to make it run: a node that creates a work and walks away has created something that never runs.
draft → checking_requirements → awaiting_goal_approval ─approve_work_goal→ planning
→ awaiting_plan_approval ─approve_work_plan→ executing ⇄ halted → verifying → done
(or failed / cancelled)ZWorkState | Meaning |
|---|---|
draft, checking_requirements | Being set up. |
awaiting_goal_approval | Gate 1. The goal is drafted; approve it before planning starts. |
planning | Drawing up the task graph. |
awaiting_plan_approval | Gate 2. The task graph is drafted; approve it before anything executes. |
executing | Tasks running. |
halted | A phase set to stop for review finished. continue_work resumes. |
verifying, done, failed, cancelled |
| Tool | Behaviour |
|---|---|
list_works(filter: ZWorkFilter) -> Vec<ZWork> | Newest first. ZWorkFilter { project_id: Option, limit: Option<u32> }. |
get_work(work_id) -> ZWork | Read one. |
create_work(work: ZNewWork) -> ZWork | Create and start planning. Settles at awaiting_goal_approval. |
update_work(work_id, update: ZWorkUpdate) -> ZWork | Edit title, description or project_id. State cannot be set. |
delete_work(work_id) -> () | Delete it. |
approve_work_goal(work_id) -> ZWork | Through gate 1: accept the drafted goal and start planning. Read final_goal and requirements_report first — that is what is being approved. |
approve_work_plan(work_id) -> ZWork | Through gate 2: accept the task graph and begin executing. Read it first with work_tasks. |
work_tasks(work_id) -> ZWorkTasks | The task graph. Empty, not an error, before planning has run. |
stop_work(work_id) -> () | Stop a running work and every task under it. |
continue_work(work_id) -> ZWork | Resume a stopped, halted or failed work: finished tasks are kept, the rest re-run. |
work_message(work_id, message, data: Vec<Datum>) -> () | Talk to the planner — steer the goal at gate 1 or the plan at gate 2. Exactly send_message on planner_session_id, without reading the work first; the reply streams back the same way. |
ZNewWork field | Type | Notes |
|---|---|---|
message | String | The goal, in prose. The title is derived from its first line. |
agent_id | Option<String> | Omit to plan with the Main Agent. |
project_id | Option<String> | Omit for the default project. Tasks run against the project's checkout, so this decides what the work may change. |
ZWork field | Type | Notes |
|---|---|---|
id, title | String | |
description | Option<String> | |
state | ZWorkState | |
project_id, agent_id | Option<String> | |
planner_session_id | Option<String> | The planning conversation, an ordinary session. |
final_goal | Option<String> | The measurable success criterion, settled at gate 1. |
requirements_report | Option<String> | Blockers found up front — a missing toolchain, a login the agent cannot perform. |
integration_branch | Option<String> | The git branch task worktrees merge into. Set once execution starts. |
failure_reason, last_activity | Option<String> | |
created_at, updated_at | Option<String> | RFC 3339. |
The task graph
ZWorkTasks { tasks: Vec<ZTask>, deps: Vec<ZTaskDep> }. An edge
ZTaskDep { upstream_task_id, downstream_task_id } means the downstream task may not start until
the upstream one is done; tasks with no edge between them run in parallel.
ZTask field | Type | Notes |
|---|---|---|
id, work_id, title, description | String | |
state | ZTaskState | |
phase_id | Option<String> | |
main_session_id | Option<String> | The session doing the work — turn_events on it follows one task. |
branch | Option<String> | The task's own git branch. |
micro_measurables | serde_json::Value | [{ "text": String, "met": bool }], the checks the task must satisfy. Untyped because it is the planner's shape. |
verification_result, failure_reason, last_activity | Option<String> | |
created_at, updated_at | Option<String> | RFC 3339. |
ZTaskState: pending; blocked (an upstream task failed); awaiting_input (the agent asked a
question in the task's own session); creating, self_verifying, verifying, recording (the agent
role holding it); merging; done; failed; conflicted (the merge into the integration branch hit
a conflict).
Letting a work through both gates
use std::time::Duration;
use zyris_attacca::{AttaccaApi, AttaccaApiClient, ZNewWork, ZWork, ZWorkState};
async fn settle(api: &AttaccaApiClient, work_id: &str, gate: ZWorkState) -> zyris::Result<ZWork> {
loop {
let work = api.get_work(work_id.to_string()).await?;
if work.state == gate || matches!(work.state, ZWorkState::Failed | ZWorkState::Cancelled) {
return Ok(work);
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
async fn run_work(api: &AttaccaApiClient, project_id: String) -> zyris::Result<()> {
let work = api
.create_work(ZNewWork {
message: "Make every fixture round-trip\n\nAdd the missing serde tests.".into(),
agent_id: None,
project_id: Some(project_id),
})
.await?;
// Gate 1: the goal.
let work = settle(api, &work.id, ZWorkState::AwaitingGoalApproval).await?;
println!("goal: {:?}", work.final_goal);
println!("blockers: {:?}", work.requirements_report);
api.approve_work_goal(work.id.clone()).await?;
// Gate 2: the task graph.
let work = settle(api, &work.id, ZWorkState::AwaitingPlanApproval).await?;
let graph = api.work_tasks(work.id.clone()).await?;
for task in &graph.tasks {
println!("{} {:?} {}", task.id, task.state, task.title);
}
for dep in &graph.deps {
println!("{} -> {}", dep.upstream_task_id, dep.downstream_task_id);
}
api.approve_work_plan(work.id).await?;
Ok(())
}In practice a person reads the goal and the plan before each approval; work_message is how to ask
for changes instead of approving.
Sessions and turns
attacca_api identity, agents, projects and sessions — sending messages, streaming turn_events, resuming, cancelling and reading usage.
Peers
attacca_api peer rendezvous — publish a node's iroh address, list and look up the account's nodes by path, get Attacca's relay and request chat files over the peer link.