Repository navigation
Let started work run in a conversation of its own, as a task - #917
Open
jerelvelarde wants to merge 18 commits into
Open
jerelvelarde wants to merge 18 commits into
jerelvelarde wants to merge 18 commits into
Conversation
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.
jerelvelarde
requested review from
davidmckayv,
guidovizoso and
tylerslaton
as code owners
October 31, 2026 00:20
jerelvelarde
requested review from
MikeRyanDev,
dustywusty,
mxmzb and
samjulien
as code owners
October 31, 2026 00:20
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
tasksrow 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, andresolved_atfor 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_tasksalso adds an index onwork_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_taskreaches 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. Itswhy,sourceandlinkcarry 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/tasksserves the person's own tasks: reply, Try again, Mark done, Reopen, the + and a report read back from the conversation. The roster andGET /api/channels/:idmark a task's conversation withtaskId, anddirect()never picks it.What is not covered
POST /api/taskshas no idempotency key, so a retried request opens a second task.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
mainby name (Shared-app and Team Bot grant timeouts, browser-routes' "computer did not start", learning tests that needagent-mastra, andtest-network-guard). Upstream is 6145 pass, 21 fail. Toolkit is 70 pass. The last commit changed onlystart_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 getsstart_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.tsandtoolkit/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_tasksfollows0069_isolated_bots; ifmaintakes another migration first, it needs renumbering and its snapshot regenerating. The toolkit's turn now throwsThreadLockBusyErroron a lock refused at first acquisition and takessystemMessages. This and #915 both add toserver/src/app.tsandserver/src/index.tsand conflict there; #918 carries both, combined.