Skip to content

DRU-1035 -- Open a chat-started build in the chat's request - #1104

Merged
czpython merged 1 commit into
mainfrom
issues/1035
Nov 6, 2026
Merged

czpython merged 1 commit into
mainfrom
issues/1035

Conversation

@dserbano

@dserbano dserbano commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

The chat's start tool moved a Linear, Jira, or GitHub ticket to the trigger status, and the tracker's webhook then dispatched the build. That request had no conversation, so the run had none either, and its park, result, and failure never reached the chat that promised to report them. The tool now opens the build itself, in the chat's request, so a new build records the chat's conversation. A ticket that already has a build running returns that run, which keeps the conversation it started with.

  • Each tracker gets get_ticket(key), which returns the ticket in the shape of a ticket.transitioned payload. Linear reads the issue through LinearClient.get_issue. Jira reads it through JiraClient.get_issue. GitHub reads the issue and its status label. The board returns Ticket.to_tracker_ticket(), which its transition also publishes.
  • start_work_item reads the ticket and calls Build.dispatch(ticket=...). It does not move the ticket. The build moves it to In Progress when it runs. That webhook dispatches nothing when In Progress is not the trigger status. If the two are the same, dispatch returns the active build and starts no second one. The tool returns the build's run id. For a ticket with a build already in flight, it returns that run's id.
  • When dispatch starts no build, it raises the reason. A ticket with no matching project repo raises TicketNotRoutable (TICKET_NOT_ROUTABLE). An already-merged ticket raises TicketAlreadyMerged (TICKET_ALREADY_MERGED). A missing GitHub connection announces build.rejected as before and raises ServiceNotConnectedError. The tool answers each one, and the webhook subscriber logs each one so the tracker does not deliver the ticket again. TicketNotMovable goes, because the tool no longer moves tickets.
  • The park post reaches the chat that started the run. A chat whose number has an admin (Conversation.admin_account_id) asks that admin, as before. Any other chat (web, Slack, GitHub, a call, or a number with no admin) gets the request as an internal message, as it already does for the result and failure. A call's park then waits for the caller's next call, as its result and failure do. The post stays in the chat.ask_admin step, so a parked run replays it unchanged.
  • The status reads use the same calls. Linear's and Jira's get_issue_status read through get_issue, and GitHub's get_status_name reads through get_ticket.

@mintlify

mintlify Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
druks 🟢 Ready View Preview Nov 6, 2026, 4:30 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@dserbano

dserbano commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

DRU-1035: how it is addressed, every test, and open questions

All results below are from f122754c, which is this change rebased on current main (v0.13.0, #1097, #1103, and #1107).

How each requirement is addressed, and why

DRU-1035 Change Why this way
Each tracker gets get_ticket(key), which returns the ticket.transitioned payload Tracker.get_ticket is implemented for Linear, Jira, GitHub, and the board. Jira's tracker and webhook share one builder, get_ticket_payload. The board's transition publishes Ticket.get_payload(), which its tracker returns. One shape per contract. dispatch receives the same payload whether a webhook or the chat opens the build. The read lives on Tracker, the class that owns the data.
start_work_item reads the ticket and calls Build.dispatch(ticket=...) without moving the ticket to the trigger status Done. Dispatch runs inside the chat's request, so Workflow.start records current_conversation_id. The conversation existed only in the chat's request. A webhook can never carry it.
build_start_marks_ticket_in_progress moves the ticket, and that webhook dispatches nothing This code is unchanged. With In Progress set apart from the trigger status, the webhook dispatches nothing. If they are the same, dispatch returns the active build and starts no second one. The issue relied on the two statuses being different. The second case covers an install where they are the same.
The tool returns the run id, and its docstring stops saying that no run exists Done. It returns the new run's id. For a ticket with a build already running, it returns that run's id, and the run keeps the conversation it started with. The agent can name the run, so it promises a report only when a run exists.
A ticket that cannot be routed answers an error dispatch raises the reason. TicketNotRoutable maps to 409 TICKET_NOT_ROUTABLE, and TicketMerged maps to 409 TICKET_ALREADY_MERGED. When GitHub is not connected, dispatch announces build.rejected and raises ServiceNotConnectedError. The tracker webhook subscriber catches all three and logs them, so the tracker does not redeliver. One exception per reason, rather than None for all three, so the tool tells the person which one applies. TicketNotMovable is deleted because the tool no longer moves tickets.
Done when: the park, result, and failure reach the chat Once the run carries the conversation, the result and failure arrive through report_result and report_failure. Before this change, the park post (chat.ask_admin) only reached a phone number's admin. Channel.asks_admin now marks the WhatsApp and call channels, where the number's admin still answers. Every other chat (web, Slack, GitHub) receives the request as an internal message, the same way it receives the result. This is a chat-platform change: a flag on the Channel class and one branch in the existing post. It uses a flag rather than a list of sources. The post stays inside the recorded chat.ask_admin step, so a run that parked before the deploy replays without a step mismatch. This is tested live below.
Status reads Linear's and Jira's get_issue_status and GitHub's get_status_name read through the same get_issue / get_ticket. One read per tracker, as the issue's "Related" section asked.

No author-surface module imports an app. Tracker reads, the route, and the dispatch errors live in software_factory. In core, only its own API clients and the Jira webhook change.

Automated tests

  • ruff check passes, ruff format --check passes on every changed file, and druks-house-rules check --base origin/main passes with this PR's title and body.
  • The 8 changed test files: 251 passed on f122754c. The full pytest backend/: 2579 passed. The only failure is the known macOS-only test_upload_dir_tar_survives_more_warnings_than_a_pipe_holds.
  • test_software_factory_start_opens_the_build_in_the_calling_chat calls the software_factory_start MCP tool the way the chat agent does, with a real token, the conversation header, the route, and dispatch. It asserts that the build starts in that conversation and that the tool answers with the run id. Only Workflow.start is replaced, and the replacement records the conversation it runs in.
  • test_the_in_progress_move_a_build_makes_dispatches_nothing: the tracker webhook for the build's own move to In Progress opens no second build.
  • Route tests, with a fake tracker that subclasses Tracker: the dispatched ticket and the run id, no status move, TICKET_NOT_ROUTABLE, a GitHub key, an unknown ticket, a missing tracker, and a tracker that refuses the call.
  • Dispatch tests: TicketMerged and TicketNotRoutable are raised before the identity check. A missing GitHub connection announces build.rejected and raises. The existing funnel tests still show that a tracker webhook raises nothing.
  • test_a_parked_run_asks_the_chat_that_started_it: a web chat receives the park request. The WhatsApp and call tests pass unchanged, so a number's admin is still asked.
  • There is one get_ticket test per tracker.

Live tests, from first to last, and why each

These ran on a local install against the real Linear, Jira, GitHub, and Slack. The bug sits between a chat request and the run's later events, which no unit test spans end to end. So each check starts a real run from a real conversation and reads what reached that conversation.

# Why Test Result
1 The fix itself: a chat start records the conversation The start route with a web chat's conversation header, on board ticket DSE-56 and Jira ticket KAN-5 ✅ Each run's conversation_id is the chat's. DSE-56 went from Backlog to In Progress, and KAN-5 from "Da completare" to "In corso". Neither moved to the trigger status.
2 Each reason gets its own answer The start route on merged ticket DSE-45, and on Linear issue OSS-91, which has no project or labels ✅ 409 TICKET_ALREADY_MERGED and 409 TICKET_NOT_ROUTABLE. Neither created a work item or a build.
3 One build per start Build count after a Linear webhook for an already-started ticket ✅ One build per ticket.
4 Real APIs, which the unit tests mock get_ticket against Linear, Jira, and GitHub ✅ This caught one bug: the GitHub client leaves out a null assignee, so the tracker now reads the first entry of assignees. The mocked test had missed it.
5 The path from the report: Slack A DM to the Slack bot: "Run board ticket DSE-57 through Software Factory…" ✅ The agent called software_factory_start within 10 s. The run recorded the DM's conversation, and the run id was posted in the DM.
6 The park reaches the chat DSE-58, started from the Slack DM, parked for plan approval ✅ The bot posted in the DM that DSE-58 is parked on the implementation plan review. Before this change, DSE-57's DM stayed silent for 20 minutes at the same park.
7 The result reaches the chat DSE-56 in the web chat and DSE-57 in Slack, each run to the end ✅ Both outcomes were posted to the conversation that started them, and the agent answered there.
8 The failure reaches the chat DSE-61, started from the Slack DM, with the planning agent's timeout overridden to 5 s so the build fails ✅ The run failed with "claude timed out after 5s". The bot posted in the DM: "DSE-61 failed to complete." The override was then removed.
9 Plain tracker intake still works now that dispatch raises reasons Board ticket DSE-60 moved to Ready for Agent by hand. Jira webhooks at the trigger status for a labelled issue (KAN-19) and an unlabelled one (KAN-20). ✅ DSE-60 and KAN-19 each opened one build, with no conversation, since no chat started them. KAN-20 answered 200, logged "Ticket KAN-20 opens no build: No project repo matches…", and created no work item.
10 A parked run survives this deploy DSE-59 was started from the web chat on main's image and parked for plan approval. Its chat.ask_admin step was recorded and posted nothing. I then deployed this change and approved the plan. ✅ The run recovered without a replay error. Its next park reached the chat through the new post, and its result followed.
11 A Slack channel thread, not only a DM DSE-62, started from a thread in a private Slack channel. The thread was opened by a real mention, and the start request in it was a signed event. The run went through two plan-review rounds and the review of PR #72, to the end. ✅ The run recorded the thread's conversation. All three parks and the result were posted to that thread, and Slack accepted each of the bot's replies.
12 A park in a GitHub-comment chat A person with a linked GitHub sign-in tagged the App on issue #73: "Run board ticket DSE-63 through Software Factory…" ✅ The run recorded the issue's conversation. At the plan park, the App commented on the issue with the plan and where to approve it.
13 The whole path as a person uses it The person typed "Run board ticket DSE-64 through Software Factory…" to the bot in Slack, answered the plan review in the DM with "Approve the plan.", and approved PR #74 on GitHub ✅ The run recorded the DM's conversation. The answer typed in the DM resumed the run. Both parks and the result reached the DM.

Before test 13, the person's DMs went to the events endpoint signed with the app's signing secret, because the person's linked Slack token is read-only. Test 13 is the person typing in Slack.

Not run live: GitHub not connected. A live run means disconnecting the App. The dispatch test covers it, and the platform answers the not-connected error the same way it does for review.

Questions for you

  1. A "working" status in Slack while the bot replies. Today the only signal is the 👀 reaction from acknowledge, which means "received". Slack has a native status line, and the change would sit next to acknowledge in slack/channel.py:

    await client.assistant_threads_setStatus(channel_id=room, thread_ts=thread_ts, status="is working…")

    Slack clears it when the reply is posted. It needs the app's "Agents & AI Apps" setting, the assistant:write scope, and a thread_ts. A DM conversation has no thread today, because get_thread_id returns "" for an im. With the AI-app setting on, Slack splits the bot's DM into threads. Druks would then need one conversation per assistant thread, as it already does for channel threads. Do you want Druks to take on Slack's assistant-thread model? If so, should it be its own issue?

  2. Build progress and cancel notices in the chat. Between the start message and the next park or result, a chat hears nothing, and a cancelled run tells the chat nothing at all. One option is a single status message per build, edited in place: planning, implementing, PR opened, then done, failed, or cancelled. Do you want this, and in which channels?

  3. "Ended with no result" is misread. When a build finishes, the chat agent is told to read the outcome on the ticket. In DSE-56, DSE-57, and DSE-64, it read a ticket still marked "in progress" and told the person the build had ended without a result, offering to retry once, even though the build had finished. DSE-62 was read correctly. Should I file this as its own issue?

  4. An unknown Linear key answers TRACKER_REFUSED. Linear answers "Entity not found", but the client expects a null issue. The route behaved the same before this change. Should I file it?

@czpython czpython left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix works. A few changes before it lands:

  1. Drop asks_admin. Conversation.admin_account_id already says whether a number's admin answers, and is_answerable_by reads it. On calls the flag is wrong: a call connection has no admin, so the admin branch returns nothing. Branch on the existing fact, and drop the flag and the two channel lines:

    async def ask_for_answer(session: AsyncSession, run: Run) -> str | None:
        if not run.input_request:
            return
        conversation = await session.get(Conversation, run.conversation_id)
        request = to_json(await run.get_ask()).decode()
        if not conversation.admin_account_id:
            return await report_outcome(
                session, run, PARKED_MESSAGE.format(run=run.id, request=request)
            )
        admin = await get_or_create_admin_conversation(session, conversation)
        ...

    A call's park then waits for the caller's next call, as its result and failure already do. docs/chat.md then names only WhatsApp.

  2. Rename Ticket.get_payload to to_tracker_ticket. get_ means a fetch here. to_ is the verb for one object in another shape (to_result, to_artifact).

  3. Drop get_ticket_payload. Jira is the only tracker that shares its builder with its webhook. Revert the core/webhooks/jira.py hunk, and build the dict in Jira.get_ticket from self._client.get_issue(key), as the Linear and GitHub trackers do.

  4. Rename TicketMerged to TicketAlreadyMerged, so the class matches its code, as its siblings do.

  5. The start tool's docstring says the build "reports to this conversation". An MCP caller with no conversation gets no report, and the server instructions tell it to poll. Say "to the chat that started it".

  6. docs/configuration.md: keep only that the tool does not move the ticket to the trigger status, and that the build moves it to In Progress. The tool's own description and errors carry the rest.

  7. test_a_parked_run_asks_the_chat_that_started_it: split the tuple assert into three asserts.

Your four questions each have their own issue: Slack's status line is #1116, progress and cancel notices are #1117, the "no result" misread is #1051 (your case is added there), and the unknown Linear key is #1118.

@dserbano

dserbano commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks. All seven points are addressed in one commit, b28f9ce5, rebased on current main.

Your review points

  1. asks_admin is removed. ask_for_answer branches on conversation.admin_account_id, as in your snippet. The flag and its two channel lines are gone, and docs/chat.md names only WhatsApp.
  2. Ticket.get_payload is now to_tracker_ticket.
  3. get_ticket_payload is removed. The core/webhooks/jira.py hunk is reverted. Jira.get_ticket builds the dict from self._client.get_issue(key), as the Linear and GitHub trackers do.
  4. TicketMerged is now TicketAlreadyMerged.
  5. The start tool's docstring says the build reports "to the chat that started it".
  6. docs/configuration.md keeps only two sentences: the tool does not move the ticket to the trigger status, and the build moves it to In Progress.
  7. test_a_parked_run_asks_the_chat_that_started_it has three asserts instead of the tuple.

Other decisions, and why

  • The rebase conflict in models.py. main now wraps the ticket create and the status write in atomic(). I kept those blocks. _emit_transitioned no longer takes status, because to_tracker_ticket reads self.status, which is already written inside the same block.
  • A private number with no admin. With the branch on admin_account_id, a chat without an admin now gets the ask itself. An operator's chat on a private number got nothing before, and now it gets the ask. test_an_operator_on_a_private_number_keeps_normal_gate_access now asserts that the ask reaches that chat. Its gate-access half is unchanged. For the same reason, docs/chat.md says the exception is "a WhatsApp number with an admin", not every WhatsApp number.
  • A call. A call runs no turns, so ask_for_answer returns nothing. The ask stays in the call's conversation for the caller's next call, as report_result and report_failure already do. test_an_operator_answers_a_run_that_a_call_parked now also asserts that the ask waits there, not only that nothing is delivered.

Tests rerun

  • Automated: On bb83f0f1 (the seven points), pytest backend/ passed 2592 tests. The only failure is the known macOS-only test_upload_dir_tar_survives_more_warnings_than_a_pipe_holds. On b28f9ce5, which adds the two test and docs follow-ups above, test_calls.py, test_whatsapp.py, and test_chat.py passed 115 tests. ruff check, ruff format --check, and druks-house-rules check --base origin/main pass.
  • Live in the web chat, on a local install of the rebased branch: I reran this because the rebase brings in DRU-1074 -- A step commits each operation on its own #1113, which changes how a step commits, and the park post runs inside the chat.ask_admin step.
Step Result
A new web chat: "Run board ticket DSE-76 through Software Factory" ✅ The agent called software_factory_start, and the run recorded the chat
The plan park ✅ The request reached the chat, and the agent summarized the plan there
"Approve the plan." typed in the chat ✅ It answered the gate, and the run continued
The review park for PR #85 ✅ The request reached the chat with the PR link

Tests not rerun, and why

  • The Slack DM, the Slack thread, and the GitHub comment chat. None of them has an admin, so their park takes the same report_outcome branch as the web chat run above. Their channel code is unchanged.
  • Jira get_ticket against the real Jira. It builds the same dict as before, now in the tracker. test_jira_reads_the_ticket_as_a_transition_payload pins every field, and the webhook is back to main's code.
  • A call, and a private WhatsApp number. These are the two paths whose behavior changed. This install has no voice or WhatsApp number to run them on, so the two tests above cover them.
  • The result and failure reaching the chat. report_result and report_failure are unchanged since the earlier live runs. DSE-76's result needs PR Skills are consulted when they come up, not read cover to cover first #85 to be approved.
  • A run parked before the deploy. The step keeps its recorded name, chat.ask_admin, as in the earlier replay test.

@czpython czpython left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good work!

@czpython
czpython merged commit ef20780 into main Nov 6, 2026
7 checks passed
@czpython
czpython deleted the issues/1035 branch November 6, 2026 19:04

This branch was successfully deployed

1 active deployment
staging - docs — b28f9ce5 Deployed Nov 6, 2026 by mintlify[bot]
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.

2 participants