fix(runtime): restore automatic Session titles and give the Host the naming effect - #4020
Conversation
…handoff Durable Message admission materializes the admitted user Message before the Run starts, and materializing a user Message locks the Session's connection. The automatic-title gate read that lock as "this Session already ran a Turn", so every Turn started through `turn.message.submit` — which is now every Desktop and CLI send — was rejected before the title effect could run, and not even the offline fallback name landed. The Session name is the only authority for "this Session is still unnamed", so the gate keeps `titleIsManual` and the default-name check and drops the connection lock. `setGeneratedTitleIfAbsent` still re-checks both under its own write, so a racing manual rename still wins. Covers both root paths end to end against a real Host: a first `turn.message.submit` and a first `turn.start` each name their Session. Generated-by: Claude Code
The title effect was split across two owners: SessionManager decided when a Session should be named, while the Host owned the model call, the residency, and the drain. That split is what broke naming — the gate read a header snapshot whose timing the Host's Message admission had quietly changed, and nothing in the Runtime could see that. Naming now lives beside recap in the Host Session-effect coordinator, which reads the header, applies the "still unnamed" rule, falls back to the Message's first line when the title model is unreachable, and writes through `setGeneratedTitleIfAbsent`. The root Turn coordinator triggers it from the one path that carries a user Message, so a compaction or a continuation opens a Run without naming anything — as before. `SessionManagerDeps` loses `generateSessionTitle` and `onSessionTitleChanged`, and its Session store port loses `setGeneratedTitleIfAbsent`; the Runtime no longer holds any part of a Host-owned effect. Also removes `generateSessionTitle` from `@maka/runtime/session-title`: it was a second, unused implementation of the same model call — no production consumer, no telemetry, no pricing — that only its own tests kept alive. The prompt, the cleaner, and the fallback stay, and the cleaner's coverage now targets the cleaner directly. Generated-by: Claude Code
`sessions:send` kept its own admission ladder: it called `turn.start` first, read `session_busy` off the failure, and only then resubmitted the same text as steering — with Skill sends carved out so they would not silently degrade. That ladder predates PR apache#3803, which made `turn.message.submit` answer `steering | followup | turn_started | blocked` and left `turn.start` for the surfaces that genuinely reserve a Turn. The Desktop composer is not one of them, so it now submits once under a stable Message identity and maps the Host's disposition. The busy race, the Skill carve-out, and the content sniffing for `/skill:` tokens all disappear: the Host already owns those decisions. Retry semantics improve as a side effect. Every send now carries a caller- owned `messageId`, so an interrupted dispatch is safe to repeat and only a second lost answer resolves as `outcome_unknown`. Generated-by: Claude Code
`connectionLocked` was written from four places. Three are authorities: the Session store freezes a route when the first user Message is appended, configuration update re-freezes on retarget, and Revision inheritance carries the frozen flag. The fourth was AgentRun, which set the flag again at Run start. On both root paths that write is redundant — the Message is materialized before the Run opens, so the header is already locked. The one thing it did carry was subagent Sessions, which never see a user Message and so were being frozen as a side effect of their first Run. That fact belongs at creation: a subagent Session's route is chosen by the spawn that created it and is never re-targeted, so it is now born locked. AgentRun stops writing headers it does not own. Generated-by: Claude Code
Adversarial review of the submit-only composer path found three seams the rewrite left behind. `outcome_unknown` is now the main failure shape of a send — the Host declining to prove what happened, not refusing the Message. WorkHub read every `ok:false` as a refusal and released the reserved root, so a Message that may already be running stopped being reconcilable. It now maps to the `unknown` admission the reconciliation machinery was built for. Its test used a `reason` the contract does not have, which is why the mapping looked covered. The `blocked` disposition returned a shape the bridge contract does not declare: no `reason`, so the renderer reported `send failed: undefined`. It now answers `skill_invocation_failed`, like the neighbouring `submitMessage` handler, and has a test. The busy-Skill test asserted a contract the Host does not offer. Only explicit `skillIds` and an orchestration override are exact-Turn intent; a `/skill:` token in the text is expanded on the queued path too, so a busy Session steers it rather than refusing it. The two shapes are now covered separately, and the UDS facade test drives the real submit operation instead of `turn.start`. Generated-by: Claude Code
Adversarial review of the naming effect found the safety argument resting on `setGeneratedTitleIfAbsent`, which was a read-then-write: a manual rename landing between the check and the write was overwritten, leaving the Session with `titleIsManual: true` and a generated name. Restoring naming widened that window from a Session's first Turn to every Turn it spends unnamed, so the write now happens at the revision the check read and answers a lost race with `null`. Naming also stops racing itself: a queued Message can open its Turn while the first title call is still out, and the second call could only lose the write, so one attempt per Session is in flight at a time. The two effects now report failure the same way. A rename that wins is an answer and stays silent; a Session store that cannot answer at all requests a drain, as recap already did. Covers the seam a mutation proved untested: no test distinguished the model's title from the fallback, so dropping the generated value entirely kept every naming test green. Generated-by: Claude Code
Adversarial review disproved the premise the previous commit was written on: a subagent Session does see a user Message. Its spawn opens the first Turn through `sendMessage`, and the store locks the route on that append like any other Session. Freezing at creation is still right — the route is chosen by the spawn and never re-targeted, so it need not wait for the prompt to land — but it closed a gap of milliseconds, not the gap the commit claimed. The real gap is on disk. A subagent abandoned before its first Message keeps `connectionLocked: false` forever now that AgentRun no longer writes it, and nothing else will ever lock it: migration 22 only reached Sessions that have a user Message. Migration 33 locks them by lineage. The new authority also had no production coverage — the only assertions ran through a test double changed in the same commit — so the Session store now proves both halves directly: an ordinary Session is born unlocked, a subagent locked. Removes the last two `connectionLocked: true` overrides, in the in-memory child headers the Runtime kernel builds for same-Session child Turns. They existed only to make AgentRun skip the write that is now gone; their `updateHeader` hook never persisted anything. AgentRun's header is `readonly`, which is what "stops writing headers it does not own" should look like. Generated-by: Claude Code
Adversarial review passThree subagents reviewed this branch independently (naming effect, Desktop send path, Desktop send path
Naming effect
Two reported findings did not survive verification and were left alone: a claimed Suites: |
With naming rehomed to the Host, `@maka/runtime/session-title` had no consumer left inside the Runtime: the prompt, the cleaner, the timeout, the source extractor and the fallback are read only by the Host's Session-effect coordinator and its model authority. The module was the last residue of the Runtime's old half of the effect. It now sits beside them in `@maka/runtime-host`, and `@maka/runtime` drops the `./session-title` export subpath. Nothing publishes these packages, so a subpath with no importer in the repository has no other demand to serve. Generated-by: Claude Code
…ranscript Second review round found a deadlock this branch made reachable. The side-chat panel treats `outcome_unknown` as a pending admission and waits for a `message_admission` event to name the Turn — but the Host projects that event only for steering Messages, which carry a `steeringEventId`. Until now the only way to reach `outcome_unknown` there was the busy fallback, which always steered; a first send on an idle fork went through `turn.start` and answered with a Turn id directly. Submitting instead means a lost answer on an idle fork leaves the panel processing forever, with send and stop both refusing to act. The panel now reconciles the way WorkHub already does: a root Message is materialized before its Run starts, so the durable transcript names the Turn it opened under the identity the panel sent. It reconciles once when the send answers unproven, and again whenever the fork produces a Turn event it cannot attribute — which is exactly the evidence that something of its own may be running. Also settles two smaller findings from the same round. The Session-effect coordinator no longer drains the Host when a generated title fails to persist: recap drains because it owes a caller an answer it cannot give, while naming owes nobody, and losing a name is not worth retiring a Host that is running Turns. And `setGeneratedTitleIfAbsent` re-reads on a version conflict instead of reporting one as "already named", so only a real rename ends the attempt. Generated-by: Claude Code
A mutation proved the gap: reverting `setGeneratedTitleIfAbsent` to an unconditional write left every suite green, so the rename it is meant to protect could regress silently. `setGeneratedTitleIfAbsent` appeared in no test in the repository — the coordinator's racing-rename test stubs the store out and asserts what the coordinator does with `null`. The Session store now proves all three answers directly: a generated title fills an absence and is refused once a Session has a name; a rename landing between the check and the write keeps the user's name and its manual flag; and a revision that moved for any other reason is re-read rather than mistaken for one. Also states in the bridge contract that a successful `sessions.send` answers with the Turn Runtime Host minted, not the identity the caller reserved. Generated-by: Claude Code
Adversarial review, round 2Three more subagents reviewed the branch: one on the round-1 fixes themselves, one on Three findings survived verification; all are fixed in P1 — the side-chat panel could deadlock (newly reachable on this branch). It treats P2 — naming no longer drains the Host on a failed write. Round 1 had me raise a failed title persist to P2 — the conditional title write had no test at all. A mutation back to an unconditional write left every suite green. Deliberately not in this PR: on a busy Session, a Skill that partly or wholly fails to resolve is silent, because the |
YayoiNanoka
left a comment
There was a problem hiding this comment.
Approved — I found no reachable P0/P1 blocker at 8605fa7b50b84bdab14eba7151bc1e95a0c519af.
The naming effect is limited to root turns that carry a user message; compaction and continuation paths do not trigger it. The generated-title write is revision-fenced, so a concurrent manual rename wins. Model-route freezing remains authoritative through the first durable user-message append and subagent creation. The Desktop admission rewrite keeps one caller-owned message identity and preserves unknown outcomes for transcript reconciliation instead of dropping potentially accepted input.
Verification on the PR head and on a clean merge with current main:
npm run build:testpassed.- 452 targeted storage, Runtime, Runtime Host, UDS, Desktop admission, Side Conversation, and WorkHub tests passed.
- No temporary test was committed or pushed; the original workspace was unchanged.
Hosted checks were still settling when I reviewed; the earlier Windows Gitoxide-helper failure was outside the changed paths and its rerun was pending. This approval records the requested code-risk threshold, not a claim that all hosted checks are complete.
点击展开中文
已批准——在 8605fa7b50b84bdab14eba7151bc1e95a0c519af 上未发现真实可达的 P0/P1 阻塞问题。
命名副作用仅由携带用户消息的根 Turn 触发;压缩与续跑路径不会触发。生成标题的写入受 revision 栅栏保护,因此并发的手动重命名会胜出。模型路由仍通过首条持久化用户消息的追加以及 subagent 创建来冻结。Desktop 的准入重写保持一个由调用方持有的消息身份,并把未知结果留给 transcript 对账,不会丢弃可能已经被 Host 接受的输入。
已在 PR head 和与当前 main 的干净合并结果上验证:
npm run build:test通过。- 452 个针对 storage、Runtime、Runtime Host、UDS、Desktop 准入、Side Conversation 与 WorkHub 的测试全部通过。
- 没有提交或推送临时测试;原工作区未改动。
审查时托管检查仍在收敛;此前的 Windows Gitoxide helper 失败不在本 PR 改动路径内,其重跑仍在等待。本次批准表示已满足要求的代码风险阈值,不代表所有托管检查已经完成。
jackwener
left a comment
There was a problem hiding this comment.
Approving. No blocking issues. I gave the storage migration the most attention, since a schema change that leaves existing installs inconsistent is the failure that users cannot work around.
The schema change is complete
SQLITE_SESSION_METADATA_SCHEMA_VERSION goes 32 to 33 with migration 33 added in the same commit, and the module's own MIGRATIONS.size !== VERSION assertion still holds — I confirmed 33 entries, max key 33, version 33.
The backfill is the part that matters, and it is correctly scoped:
WHERE json_extract(payload_json, '$.connectionLocked') = 0
AND json_extract(payload_json, '$.subagentParent') IS NOT NULLThat is exactly the set whose invariant changed — existing subagent Sessions that were unlocked at creation and would otherwise never be locked now that AgentRun no longer writes the flag. It is also the same SQL shape as migration 22, down to the json('true') and the MAX(committed_at, ...) clamp, so it follows the established idiom rather than inventing one.
The diagnosis holds up against the diff
The claim is that the auto-title gate became unreachable because it required !header.connectionLocked while an admitted root Message freezes the route before the Run starts. The removed code in session-manager.ts confirms the first half directly — the gate really was !header.connectionLocked && !header.titleIsManual && header.name === DEFAULT_SESSION_NAME && sourceText. Dropping that one term and keeping the name check is the minimal fix.
Other things I verified
- Naming hangs off the right path.
startRootMessageTurncarries the hook, and theresumeSafeBoundaryContinuationbranch keeps the bareonRunStarted, so a compaction or continuation opens a Run without naming. That is what the description promises and the ternary shows it plainly. - The optional hook is actually wired.
nameSessionFromRootMessageis declared?.-optional on the constructor, which would let a missing wire silently reproduce the original bug. It is passed atexecution-composition.ts:1105, andnameSessionIfUnnamed/onSessionNamedare wired alongside it. - The one-at-a-time guard is sound.
nameSessionFromRootMessagescans#titleAbortsand registers the new controller with noawaitin between, so two calls in the same tick cannot both pass the check. - The file move left nothing dangling.
./session-titleis removed frompackages/runtime'sexportsin the same commit the file moves out, and no reference to it survives in that package — so no export pointing at adistfile that will not exist.
Two small things, neither blocking
1. Aborting a title call does not actually stop it (P3). shutdown aborts every controller in #titleAborts. In #nameSession, the catch around generateTitle does not distinguish an abort from an unreachable model, so an aborted call falls through to fallbackSessionTitle(sourceText) and still performs the store write. The effect is cosmetic — a Session named from the Message's first line instead of the model's phrasing when the Host shuts down mid-call — and the write is wrapped, so nothing can throw out of it. But it does mean abort downgrades the effect rather than cancelling it, which is not what the surrounding comment ("a store that cannot answer leaves the Session unnamed for the next root Message to retry") leads a reader to expect. An if (abortSignal.aborted) return; before the write, or a sentence saying the fallback is deliberate on abort too, would settle it.
2. setGeneratedTitleIfAbsent throws where its type suggests it returns (P3). After three version conflicts the loop rethrows SessionMetadataVersionConflictError rather than answering null, which makes the trailing return null unreachable. The only caller today wraps it in a catch, so nothing is wrong in this PR. It is the next caller that reads Promise<SessionHeader | null>, treats null as "did not happen", and does not expect a throw.
Note on state
test was still in_progress when I finished, with audit, ubuntu-latest, macos-latest, windows-latest and windows_recovery already green. Nothing in my review depends on it, but the unit suites are what would catch a regression in the naming path, so it is worth confirming terminal green before this moves.
I also see @YayoiNanoka approved this same head, so treat this as a second pass rather than the first.
Posted by an automated review agent operated by @jackwener. This is not an
independent human review and does not satisfy the committer review required by
CONTRIBUTING.md. A human is accountable for this comment — please push back if
anything here is wrong.
简体中文
本条评论由 @jackwener 运行的自动化审查程序发出。它不构成 CONTRIBUTING.md
所要求的独立人类审查,也不能替代人类审查。有人类对本条评论负责,如有错误请直接指出。
M4n5ter
left a comment
There was a problem hiding this comment.
Approved at exact head 8605fa7b50b84bdab14eba7151bc1e95a0c519af: I found no reachable P0/P1 blocker.
The change restores automatic Session naming under Runtime Host ownership, revision-fences generated titles so a concurrent manual rename wins, and removes the Desktop turn.start → busy → submit admission ladder without losing message identity or unknown-outcome reconciliation. Route freezing and connectionLocked now each have one durable authority, including subagent lineage migration.
I independently reproduced one non-blocking P2 already disclosed in the author's round-two review note: a busy textual /skill: message can execute its successfully resolved Skills while the Desktop loses partial-resolution feedback. All-failed resolution and explicit exact-Turn Skill intent still fail closed, so this does not cross the requested P0/P1 blocking threshold; I am not duplicating the existing public report as another inline thread.
Verification on this head:
- 555 focused Storage, Runtime, Runtime Host, Desktop admission, Side Conversation, WorkHub, SessionManager, and AgentRun tests passed.
- Core, Storage, MCP, Runtime, Runtime Host, Computer Use, Eval, CLI, Desktop main, and Desktop preload builds passed.
- Biome and
git diff --checkpassed for the changed files. - The current-main synthetic merge was clean; its only overlapping bridge-contract edit combined additively.
- Hosted exact-head checks are terminal green.
Posted by an automated review agent operated by @M4n5ter. This is not an
independent human review and does not satisfy the committer review required by
CONTRIBUTING.md. A human is accountable for this comment — please push back if
anything here is wrong.
简体中文
本条评论由 @M4n5ter 运行的自动化审查程序发出。它不构成 CONTRIBUTING.md
所要求的独立人类审查,也不能替代人类审查。有人类对本条评论负责,如有错误请直接指出。
Two review follow-ups on the Session naming effect, both about a path answering something other than what its shape promises. Shutdown aborts the title call, but the catch around the model did not tell an abort apart from an unreachable model, so a draining Host still wrote the Message's first line as the name. Abort now retires the effect: the next root Message names the Session, which is what the surrounding comment already told a reader to expect. `setGeneratedTitleIfAbsent` rethrew the version conflict on its last attempt, so a caller reading `SessionHeader | null` had to also expect a throw for the one outcome the null already means. Exhausting the attempts now answers null like any other lost race; a conflict is the only error it swallows.
Summary
New conversations stopped naming themselves. The auto-title gate required
!header.connectionLocked, and PR #3721 made that condition unreachable on the Message path: an admitted root Message is materialized into the transcript before the Run starts, and that append freezes the model route. By the time the Runtime asked "is this Session still unlocked?", the answer was always no, so every Session kept the nameNew Chat. Dropping that term from the gate restores naming; the "still unnamed" rule was always the real guard.The gate was only unreachable because the effect had two owners.
SessionManagerdecided when to name, while the Host owned the model call, the residency and the drain — so a timing change inside Host admission could silently disable a Host effect with nothing in the Runtime able to see it. Naming now lives beside recap in the Host Session-effect coordinator, triggered from the one root path that carries a user Message, so a compaction or a continuation still opens a Run without naming anything.SessionManagerDepslosesgenerateSessionTitle,onSessionTitleChangedandsetGeneratedTitleIfAbsent; the Runtime no longer holds any part of a Host-owned effect.Two cleanups this made visible, rather than a follow-up branch that never opens:
sessions:sendstill ran its own admission ladder — callturn.start, readsession_busyoff the failure, resubmit as steering, with a Skill carve-out and/skill:content sniffing to keep that ladder honest. PR refactor: make Runtime Host the sole Message admission authority #3803 madeturn.message.submitthe admission authority, and the composer is not a surface that reserves a Turn. It now submits once under a stable Message identity and maps the Host's disposition; the whole ladder goes.connectionLockedwriters. Four writers, three authorities. AgentRun's write at Run start was redundant on both root paths — the Message is already materialized — and the only thing it really carried was subagent Sessions, whose route is chosen by the spawn that created them and is never re-targeted. That fact belongs at creation, so a subagent Session is now born frozen, with a metadata migration backfilling existing lineages.Verification
packages/runtime-host/src/__tests__/execution-host-session-title.test.tsdrives a real forked Host and covers both root paths end to end: a firstturn.message.submitand a firstturn.starteach name their default-named Session, and each leaves the Session's route frozen. It fails onmainwith'New Chat' !== 'draft the release notes'on the submit case.@maka/runtime2911,@maka/runtime-host1279,@maka/storage975 and the Desktop main suite 1578 pass; desktop typecheck,biome formatandbiome lintclean.Review focus
Behavior given up on purpose: a busy Desktop send no longer degrades a Skill invocation to steering locally — the Host's
session_busypropagates instead. The queued path resolves a textual/skill:token without reporting partial resolution back to the composer; that gap is tracked in #4026. Retry semantics tighten as a side effect, since every send now carries a caller-ownedmessageId, so an interrupted dispatch is safe to repeat and only a second lost answer resolves asoutcome_unknown.AI use
Select exactly one:
Tool(s) and scope: Claude Code — diagnosis, implementation and tests, reviewed by the author. Every commit carries
Generated-by: Claude Code.Checklist
Does this PR entail a change in behavior?