Skip to content

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

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

jerelvelarde wants to merge 18 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. The row holds only what the person or the start can say, and resolved_at for Mark done.

Status is derived on every read, never stored. The work queue, the approval inbox and the question queue already know whether a turn is queued, running, out of runs, or waiting on the person, and so does the last thing said in the conversation. A status column would be a second copy to keep in step with all four. The cost is several reads per task, so 0070_tasks also adds an index on work_items (kind, payload->>'ownerUserId', payload->>'taskId'): without it, status, the report and the question inbox each scanned every task turn ever written, for every person.

A turn runs at most twice. Once, and again after a throw or a restart; then it reads failed, with the reason in plain words, until Try again. A lock refused on a turn's first ask means it never ran, so the toolkit now names that one conflict (ThreadLockBusyError) and the queue gives the attempt back.

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 stopped turn has finished, so a reply sent before the person allowed a step could otherwise run after the resume. Replies are taken onto the queue before they are answered, so they survive a restart.

start_task reaches only turns a person started. Built-in Bots only get it; a Bot behind a remote agent framework starts tasks from its suggestions or the +. It is never offered to 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 why, source and link carry only what the person said or what the work came from, never the Bot's own reasons, because the report shows them as the person's.

Task turns are capped. At most three run at once per person, counted under a lock so racing replicas can't pass it, and at most ten in one process. A turn held back reads Working and starts when one finishes.

/api/tasks serves the person's own tasks: reply, Try again, Mark done, Reopen, the + and a report read back from the conversation. 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 report lists no emails or events a task read.
  • POST /api/tasks has no idempotency key, so a retried request opens a second task.
  • A deploy during a turn spends one of its two runs.
  • A turn that hangs keeps its task Working until its lease lapses, about fifteen minutes, and the retry can then find the thread still running and read failed. It happened twice in about eight runs while several servers shared one computer.
  • Any question in a reply's last paragraph reads as asking, and a fenced block with a blank line 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.
  • The guidance on how a reply ends reaches the model labelled "Selected skill 1".
  • 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 full server suite is 6286 pass, 20 fail, 18 skip; all 20 also fail on upstream main by name (Shared-app and Team Bot grant timeouts, browser-routes' "computer did not start", learning tests that need agent-mastra, and test-network-guard). Upstream is 6145 pass, 21 fail. Toolkit is 70 pass. The last commit changed only start_task's parameter descriptions and adds a test; the task tests, rerun after it, pass.

  • server/tests/tasks.test.ts: status precedence, plain failures, titles.
  • 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; retries; Allow once in order; who gets start_task, and at most five; three per person.
  • server/tests/task-turn.test.ts: a turn stopped for Allow once waits rather than failing; one refused the lock on its last run says nothing.
  • server/tests/task-report.test.ts: decisions and updates in order. shared/task-brief.test.ts: the ask framed and read back.
  • server/tests/work-queue.integration.test.ts and toolkit/tests/lease.test.ts, extended: serial groups, the per-value cap, live-lease-only writes, and a lease let go one interval before it lapses.

Merge notes

Migration 0070_tasks follows 0069_isolated_bots; if main takes another migration first, it needs renumbering and its snapshot regenerating. The toolkit's turn now throws ThreadLockBusyError on a lock refused at first acquisition and takes systemMessages. This and #915 both add to server/src/app.ts and server/src/index.ts and conflict there; #918 carries both, combined.

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.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant