Skip to content

Let started work run in a conversation of its own, as a task - #917

Open
jerelvelarde wants to merge 25 commits into
CopilotKit:mainfrom
jerelvelarde:jerel/openbot-tasks-server
Open

jerelvelarde wants to merge 25 commits into
CopilotKit:mainfrom
jerelvelarde:jerel/openbot-tasks-server

Conversation

@jerelvelarde

@jerelvelarde jerelvelarde commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Let started work run in a conversation of its own, as a task

The problem

Start on a suggestion queues one run in the conversation the suggestion was delivered to, so that conversation fills with the work. The run has one attempt: if it throws or the server restarts, it is over, and nothing can say it failed, try it again or steer it. A Bot asked in chat for something large does it inline.

The approach

A task is a record and a conversation. Start opens a new conversation with the Bot and a tasks row naming it, in one transaction so a lookup never sees one without the other, and the turn runs there. POST /api/tasks takes an optional clientId the browser makes for each Start, so a repeat answers the task the first one opened; two at once race on a unique index, and the loser rolls back without leaving a conversation.

Status is derived on every read, never stored. The work queue, the approval inbox, the question queue and the conversation already know whether a turn is queued, running, out of runs or waiting on the person; a status column would be a second copy to keep in step. The cost is several reads per task, so 0070_tasks also indexes work_items by kind, owner and task; without it, each read scanned every task turn ever written.

A turn runs at most twice, and never longer than seven minutes. Once, and again after a throw, a restart or the time limit; then it reads failed, with the reason in plain words, until Try again. The runner stopped the model at five minutes but not the steps around it, so a hung turn could read Working for a quarter of an hour; now its signal is aborted at seven. A run refused before anything ran, whether its lock was busy (ThreadLockBusyError, which the toolkit now names) or the conversation's last run was still winding down, goes back on the queue without spending its attempt.

A task's work runs in the order the person gave it. Its turns, the person's replies, answers to question cards asked there, and the turns Allow once carries on share one serial order (TASK_ORDER). Allow once is in it because a reply sent before the person allowed a step could otherwise run after the resume. Replies are queued before they are answered, so they survive a restart.

start_task reaches only turns a person started, and only multi-step work. Built-in Bots get it, never a task's own turn, and one turn starts at most five. A task's turn runs as the person, so a routine, responsibility, wake-up or hand-off acting on an injected email must not be able to start one. Its description sends only clearly multi-step work, has the Bot answer "On it. I'll put the result in Tasks.", and keeps a single question in place. Its why, source and link carry only what the person said or the work came from.

The report reads as the person's. It opens with the ask, read back from the task's first message. Its why comes only from a suggestion, since an asked task's could hold the Bot's reasoning. Each update is a reply's conclusion, not its preamble, and a done task's next line is "Marked done by you". If the conversation can't be read in eight seconds, the report answers from the ledgers, marked partial, so /report never hangs. The instruction on how to end a reply reaches the model under its own description, not as "Selected skill 1".

Task turns are capped: three at once per person, counted under a lock so racing replicas can't pass it, and ten per process. The roster and GET /api/channels/:id mark a task's conversation with taskId, and direct() never picks it.

Before, main has no tasks API; after, a task starts, works, reports and is marked done, and its row and work item are read back

What is not covered

  • This branch draws nothing. Merged alone, a task's conversation shows as an ordinary one and a reply typed there runs outside the task's order; Let a person see every task by what it needs from them #918 is the screens.
  • The worst case is still about two seven-minute runs of Working before a task reads failed, and a step that ignores its abort keeps running in the background.
  • The report lists no emails or events a task read; a deploy during a turn spends one of its two runs.
  • Any question in a reply's last paragraph reads as asking, and a fenced block can leak into the next-step line. A reply and a question card can both answer one question, and a suggestion can stay Started with no task.
  • A finish after its lease lapsed is ignored, and the person-response sweep doesn't renew its lease.

Verification

typecheck, lint, format:check and the assertion-shape guard are clean. The app suite is 2129 pass, 0 fail, as on upstream main, since no app code changes. 274 server tests pass across 20 task, proactive, work-queue and approval files.

  • server/tests/tasks.test.ts: status precedence, plain failures, "Marked done by you", and the words start_task gives the model.
  • server/tests/tasks.integration.test.ts: status from the ledgers, never stored; another person's task is no task; reads use the index.
  • server/tests/task-threads.integration.test.ts: Start opens its own conversation; a repeated clientId opens one task; the time limit; a refused run re-queues; Allow once in order; at most five, three per person.
  • server/tests/task-turn.test.ts: a turn stopped for Allow once waits rather than failing.
  • server/tests/task-report.test.ts: the ask, why only from a suggestion, conclusions as updates, partial; shared/task-brief.test.ts reads the ask back.
  • toolkit/tests/turn-settle.test.ts and toolkit/tests/lease.test.ts, extended: a described system message keeps its name; live-lease-only writes.

Merge notes

The stack is rebased on upstream main at c396a5f6. Migration 0070_tasks follows 0069_isolated_bots and now includes client_id with its unique index; another migration on main first means renumbering it and regenerating its snapshot. The toolkit's turn now throws ThreadLockBusyError and takes systemMessages with an optional description. This and #915 conflict on adjacent imports in server/src/app.ts and server/src/index.ts; #918 carries both.

What a task's conversation needs from the queue, with nothing in OpenBot
using it yet:

- `claim({ serialBy })` runs one item at a time per group. An item waits
  while another of its group holds a live lease, or while an older one is
  unfinished with attempts left. A payload field's name groups one kind by
  that field; a `SerialOrder` lets several kinds share one order, each
  with its own attempt cap and its own way of naming its group, so the
  queue never needs to know what a group is.
- `release({ unstarted })` gives back the attempt a claim spent when the
  work never began.
- `renew`, `finish` and `release` act only on a live lease. An expired
  lease on its last attempt keeps its owner, and a late heartbeat, finish
  or refund from that owner could otherwise revive it beside the sibling
  now running in its place.

`ThreadLockBusyError` names a lock refused on a turn's first acquisition,
the one conflict that is safe to defer. `pendingPersonQuestion` is the
person's waiting questions, less any a reply has already answered; the
inbox and `respond` use it, which changes nothing until replies carry
`questionIds`.
A task is background work a Bot does in a conversation of its own. This
adds where tasks are kept and how they are read; nothing opens one yet.

The `tasks` table holds what only the person or the task's start can
say: its Bot, its conversation, its title and why, where it came from,
and `resolved_at`. There is no status column. Every read works the
status out from the ledgers that already know it: the task's turns on
the work queue, approvals waiting in the inbox, questions its Bot asked,
and the last thing said in its conversation. A turn runs at most twice;
one out of runs reads as failed, with the reason in plain words.

`/api/tasks` serves the person's own tasks: `GET /`, `GET /:id`, and
`POST /:id/resolve` and `/reopen` for Mark done and Reopen. Another
person's task is the same as no task.

Migration 0070 creates the table.
The thread lock refused when a turn first asks for it now throws
`ThreadLockBusyError`, with the platform's 409 as its cause. That one
conflict means the turn never ran, so a queue can put it back without
spending an attempt. The same 409 later in a run, or on a renewal, may
follow a tool call or a model's spend, and is raised as it comes.

A turn also takes `systemMessages`: instructions the server resolved for
it, such as the skills picked for a reply to a task. They are kept as
history rows and given to the model as context, since a built-in Bot's
model leaves system rows out.

`keepLease` gives up exactly one interval before the lock lapses, not one
tick after: with renewals failing, the `>` let the lock expire on the
tick it should have stopped.
Started work used to run in the conversation the suggestion was delivered
to, so that conversation filled with the work. Start now opens a task: a
new conversation with the Bot and the record that names it, written in one
transaction so a lookup never sees one without the other. Its turn runs
there, queued and picked up straight away.

The proactive engine runs a task's turn at most twice: once, and once more
after a throw or a restart. A turn refused the conversation's lock on its
first ask is given back without spending a run. Out of runs, it stays on
the queue with its reason, which is what reads as failed, and the Bot says
so in the conversation. A turn queued before retries existed is never run
a second time. Try again (`again`) reruns the failed turn, keyed so two at
once run once. A reply (`reply`) is taken onto the queue before it is
answered, with the questions it answers and any files and skills checked
and kept, so it survives a restart. A task's turns, its replies and the
answers to question cards asked in it share one order (`TASK_ORDER`), so
none overtakes another.

A task's conversation stays out of the person's own: `direct()` never
picks one, the roster marks it with `taskId`, research never adopts it,
and its title comes from the ask inside the task frame. While a task's turn
holds its lease, the conversation's work list says so; the engine says it
is no longer busy once the turn is off the queue, so that list is read
after the turn has left it.

`POST /api/proactive/suggestions/:id/start` answers `{ suggestion, task }`,
`GET /api/proactive/suggestions/:id` reads one suggestion whatever its
status, and `/api/tasks/:id/reply` and `/again` steer a task.
A Bot OpenBot runs itself can now hand anything bigger than a quick
answer to a task of its own with `start_task`, and answer "On it" where it
was asked, so that conversation stays one thread and the work happens in
the task's. The tool opens the task through the engine's `startAsked`,
the path a started suggestion takes, so both run, wait and finish alike.

It is offered only to built-in Bots (`agents.type = 'built_in'`). A Bot
behind a remote agent framework composes its own turn at somebody else's
server, so it is not told to hand work on; its tasks start from its
suggestions and from the +.

`POST /api/tasks` is the + on Tasks: `{ botId, instruction, title? }`
opens a task with any of the person's Bots and starts it, titled from the
ask's first sentence when no title is given. A Bot the person cannot
reach is the same as no Bot.
`GET /api/tasks/:id/report` reads a task's conversation back as the
report the task page shows above it: the person's decisions on approvals
asked there, their replies, and the Bot's update as each turn ended, in
the order they happened. Nothing is stored. The conversation comes from
Intelligence, as the runtime holds it; its messages carry no times, so
each time comes from whichever ledger saw that moment, the approval
inbox or a turn's work item, or there is none.

A turn still running is left out until its own completion is on the
queue, and a rerun of an accepted reply adds an update without adding
the person's reply twice.

The report has no list of the emails and events a task read: that needs
connector-specific readers OpenBot does not have.
The settle test now checks the queue at the moment the conversation is
told it is no longer busy: the turn must already be finished there, which
is the reason the signal moved off the turn. Its assertions also keep
`?.` out of their subjects, as the assertion-shape guard requires.

The accepted-answer fixtures ended the earlier turn on the host's clock
just before a reply stamped on the database's. A host clock a few
milliseconds ahead put that end after the reply's failure, and the task
read as ready instead of failed. They now end it a minute earlier.
…n it must

The frame told the Bot to "end with what you did and anything still waiting on them", and
gpt-5.4 wrote that as literal "What I did:" and "Still waiting on you:" labels; the second
became the task's next-step line on Tasks. Every task turn now carries a system message
saying how to end. A question there reads as Needs you, so the Bot asks one only when it
can't go on without an answer. The frame itself stays word for word, so tasks opened
before this are still read as tasks.
… of what isn't waiting

The first wording came back as "Nothing is still waiting on them." The guidance now asks
for the ending written to the person as "you", and to leave out what waits on them when
nothing does.
A turn stopped for Allow once with no words of its own recorded the toolkit's "The turn is
waiting for human input." as the task's next step. It now reads "Waiting for you to allow
a step.", or "Waiting for your answer." for anything else, and a message of the gate's own
is kept as it was.
start_task was offered to every turn of a built-in Bot, and the task's turn
always runs as the person. A routine, responsibility, wake-up or hand-off that
read an injected email could start a task that ran past the rules written for
unattended runs, credited to the person on the trail.

It is now offered only to turns a person started, never to a task's own turn
(built by its own runner, which tells the tools so), and one turn starts at
most five tasks.
A task's status read whether its last reply asked from the line its
conversation keeps, which is cut to 200 characters with an ellipsis. Any
closing question longer than that lost its question mark there, and the
task read Ready for review instead.

Whether the reply asks is now read from the whole reply as the turn ends,
and kept on the turn's work item as it finishes (`finish` can now merge
what the work came to into its payload). Status uses it when the last line
is that reply; a reply from before this, or anything else said there, is
read from the line as before.
Nothing purges task turns or the answers to a task's questions, and the
reads of them had no index: a task's status, its report and the question
inbox's check for answers already taken each scanned every task turn ever
written, for every person, and the inbox runs on every page that counts
what is waiting.

`work_items_owner_task_idx` indexes work by kind, person and task, folded
into the unpublished 0070_tasks migration and its snapshot. The status
read now names the person too, as every task turn does, so each of those
reads is a lookup by person. A test plans them over a queue holding many
people's turns.

A task with no turns on the queue read as never started however old it
was, so clearing old turns would have failed every old task. One whose
conversation has had anything said in it did start, and now reads from
what was said.
A task's turn that stops for Allow once has finished, and its resume was
outside the order a task's turns take. So a reply sent before the person
allowed the step could run after the resume, or one sent after it before,
and the thread lock kept them apart but not in order: the resume carries
on from the conversation as it was when the turn stopped.

The approval resume sweep now claims under the task's order, which finds
an approval's task through the thread it was raised in, as it finds a
question card's. A task's work runs in the order the person gave it.
Each start launched its own drain, so task turns ran side by side without
limit: one reply starts at most five tasks, but many replies or
suggestions could still run dozens of headless model turns at once for
one person.

A claim can now leave an item queued while its value already has so many
running (`runningAtMost`), counted again under a lock so replicas racing
for one person cannot pass it. Task turns use it to run at most three per
person, across replicas, and the engine runs at most ten in one process.
A turn held back reads Working and starts as soon as one finishes, from
the turn that made room, not at the next sweep.
…turn

When Allow once, a question card's answer or a sign-in carried a turn on,
its Bot was rebuilt through the generic approval runner, even in a task's
own conversation. So the task's resumed turn was offered start_task again,
which a task's own turn never is.

Those three now carry a turn on through one runner that builds it as a
task's turn when its thread is a task's conversation, and as before
anywhere else.
The roster's summary carried a channel's task, but `GET /api/channels/:id`
did not, so a link that opened a task's conversation by its channel had no
way to know it was a task's. The browser drew it as an ordinary conversation,
where a reply runs outside the task's queue.

The channel read and the channel a create answers with now carry `taskId`,
null for an ordinary conversation.
A recording showed a task's report giving the Bot's own reason for making a
task as its why: "This is a multi-step planning request that benefits from a
dedicated task and a polished itinerary." The field was described only as
"why it matters or by when", which a model read as its own reasoning.

start_task now says why holds only what the person said about why it matters
or by when, in their words, is left out when they said neither, and is never
the Bot's reason for making a task. Source and link had the same gap: source
is never what kind of request it is, and link is the email or event it came
from as read, never a page to use for the work or one made up.

A model following a description can't be unit-tested; the test pins that the
JSON schema the model is given says these things.
A task marked done showed the Bot's last line as its next step, so a closing
question read as still waiting on the person. A resolved task's next is now
"Marked done by you"; the app shows when from resolvedAt.

A recording also showed "Nothing is waiting on you." as a finished task's next
line. A closing sentence that only says nothing waits on the person is now set
aside, so a ready-for-review task's next line is the Bot's closing words before
it, or null when that was all it said. Matched as a whole sentence, so a finding
such as "Nothing in it needs a reply from you" is kept.
Each update was the first sentence of the turn's last message, which is usually
a preamble ("Here's a thoughtful plan"). An update is now the reply's
conclusion: the last paragraph the Bot wrote to the person, without lists,
drafts or a line that only says nothing waits on them, on one line and cut at a
word past 280 characters.

The report now carries the person's ask, read back from the task's first
message with readTaskBrief, or the instruction its first turn was queued with.
Its why is only a suggestion's own reason: an asked task's why could hold the
Bot's reasoning ("a multi-step planning request that benefits from a dedicated
task"), guarded only by wording in start_task, and the ask is the person's
words.

The conversation read also gets a time limit. Past eight seconds the report
answers from the ledgers alone, marked partial, so the task page never waits on
an Intelligence read that doesn't answer.
start_task's description sent "anything bigger than a quick answer" to a task,
so a single question left the conversation and the person lost the answer in
place. It is now for clearly multi-step or long-running work only: research
across several sources, drafting a document, work across several sites, or
anything that takes more than a few replies. A single question, or anything
with a short answer, is answered in the conversation.

The Bot then replied only "On it.", which never said where the work went. The
description and the tool's result now have it reply "On it. I'll put the result
in Tasks.", in the words of the card it leaves ("Working in Tasks").

Both are text for the model, so this changes behaviour. Whether a model follows
them can't be unit-tested; the tests pin the key phrases it is given.
…its turn

A recording saw a task's first run never finish and its retry fail on
ThreadAlreadyRunningError, so the task read Working for about 16 minutes and
then failed as "the run was cut off partway through".

The turn runner stops the model's run at five minutes, but not the steps around
it, and the worker renewed the turn's lease for as long as it waited. A task's
turn now has a limit of its own, seven minutes start to finish: past it the
turn's signal is aborted and the item is let go with "The task's turn was
stopped after 420s.", which reads as "the run took longer than 7 minutes" on the
task and in its conversation.

The runner refuses a run while the one before it in the conversation is still
winding down. That refusal comes before anything runs, so, like a lock refused
on the first ask, the turn now goes back on the queue without spending its
attempt and says nothing in the conversation.
POST /api/tasks had no idempotency, so a double-submitted Start opened two
tasks and ran two turns. It now takes an optional clientId, a uuid the browser
makes for each Start. A repeat with the same clientId from the same person
answers the task the first one opened and starts nothing more.

The id is kept on the task, unique per person (folded into the unpublished
0070_tasks). Two at once race on that index: the loser's transaction is rolled
back, so it leaves no conversation behind, and it answers the winner's task.
Its turn is offered under the same key, so it is never queued twice.
Every system message a headless turn carried reached the model as context
"Selected skill N", so a task's instruction on how to end its reply read as a
skill the person had picked.

A turn's system message can now carry a description, which names it in the
model's context and stays off its history row. Without one it is still a
selected skill, numbered among the skills alone. TASK_ENDING goes as "How to
end your reply in this task".
The report test's task now carries the origin and why a report reads, and the
turn that never answers is typed as the turn it stands in for, so the server
tests type-check against their baseline again.
@jerelvelarde
jerelvelarde force-pushed the jerel/openbot-tasks-server branch from 442eed9 to 2ff6a41 Compare November 2, 2026 17:19

This branch has not been deployed

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

Labels

None yet

1 participant