feat: webmux oneshot + Linear round-trip, server-driven watcher - #238
Conversation
Adds `webmux oneshot` for start-to-finish runs that stream the agent
conversation to stdout without changing tmux focus, plus per-worktree
`onMergeAction` ("close" | "remove" | null) configurable via CLI flags
(`--close-on-merge` / `--remove-on-merge`) and a frontend toggle in the
worktree row menu. `oneshot --resume <branch>` re-attaches to a stuck
session and can send a follow-up prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Linear-backed handoff path so a webmux session can be picked up on another machine (or by someone else): - oneshot --post-to-linear <issue-id|team-key>: when the run ends, upload the conversation as a JSON attachment + summary comment. Team keys auto-create a new issue first. - oneshot/add --resume-from-linear <issue-id>: resolve a branch and conversation context from the issue's webmux attachment (preferred) or its linked GitHub PR, then create the worktree. --branch overrides the resolved branch. - New CLI: webmux linear post <branch> <issue-or-team>. - Frontend: "Post conversation to Linear…" worktree action, plus "Resume from Linear issue" field in the create dialog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ssue body The flag handles both fresh-issue kickoffs and resumes, so the "resume" prefix was misleading. Renames everywhere and updates the seed builder to always pull the issue title + description into the conversation context, regardless of whether a webmux attachment or linked PR exists. Also: - New --linear <id> shorthand for --from-linear ID --post-to-linear ID (round-trip on the same issue, the common pattern) - Conversation context now always includes a Linear hint reminding the agent to reference "Fixes ENG-X" in PR title/body so Linear auto-links the PR if the branch doesn't match issue.branchName - Contract field resumeFromLinear → fromLinear Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Now that --linear handles the "round-trip on an existing issue" case, posting an arbitrary session into a pre-existing issue is redundant and muddles the mental model. Tighten the surface so: - --post-to-linear TEAM — creates a new Linear issue in TEAM, posts there - --linear ENG-X — loads the issue body as context AND posts back to it - --from-linear ENG-X — loads only, no post `webmux linear post <branch> <team-key>` and the dashboard Post-to-Linear dialog get the same restriction. Issue-id input produces a helpful error that points at --linear. Also simplifies the --linear conflict logic — mixing --linear with --from-linear or --post-to-linear is now always rejected (no more "same id is ok" exception). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Collapses --from-linear and --post-to-linear on oneshot into a single --linear <ID|TEAM> that picks the right behavior from the input shape: - ENG-123 (issue id) → load context from the issue + post results back (the round-trip case) - ENG (team key) → no seed, create a new issue in the team when done --from-linear stays on `webmux add` (no post lifecycle there), and the manual `webmux linear post <branch> <team-key>` primitive is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /ws/agents/worktrees/:name handler short-circuits for non-Codex providers (server.ts:733), so Claude oneshots got the initial snapshot and then sat silent while the tmux agent was happily running. The dashboard works around this with history polling alongside the WS; do the same in oneshot so the conversation actually streams to stdout. Dedup in printNewMessages makes this a safe additive change for Codex too (acts as a fallback for missed deltas). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The parse-fail warning fired on every history read for any line that
survived trim() but wasn't valid JSON — and with the new oneshot history
polling that meant ~30 warnings per minute. The line content was
typically empty-looking control characters that render blank.
- Add startsWith('{') as a cheap pre-filter so obvious non-JSON garbage
doesn't reach JSON.parse.
- Downgrade the remaining log to debug so legitimate parse failures
(e.g. mid-write partial JSON) don't spam stderr in normal use.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…exit, linear upload Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
So the agent's PR has the issue id in its branch name and Linear's GitHub integration auto-links it. The team-key path now collapses into the same round-trip flow as `--linear ENG-123`. Also makes oneshot tool-call output compact (Claude-Code-style `● Tool(arg)` / ` ⎿ result`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`--keep-open` now means "leave the worktree session running after oneshot exits" — oneshot always exits on idle in both modes. Drops the implicit `--close-on-merge` default so on-merge behavior matches `webmux add` (no default; respects global autoRemoveOnMerge). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the auto-close service, the per-worktree `onMergeAction` override, its CLI flags (`--close-on-merge`/`--remove-on-merge` on add+oneshot), the UI dropdown, and the related API endpoint. The pre-existing global `autoRemoveOnMerge` still gates auto-removal after PR sync. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the redundant "From Linear issue (optional)" text input on the
create dialog. The bottom-left LinearPanel was already the canonical
"start from Linear" entry point; now its click path sends
`fromLinear: { issueId }` so the backend injects the issue header and
any prior webmux attachment as context — same plumbing as the CLI's
`--linear ENG-123`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…erge-action # Conflicts: # backend/src/server.ts # bin/src/completions.ts # bin/src/webmux.ts # frontend/src/App.svelte # frontend/src/lib/WorktreeList.svelte # packages/api-contract/src/contract.ts
CI typecheck caught these after the revert + main-merge: test object literals were still spreading `onMergeAction: null` / passing `onsetonmergeaction` props, and the merge accidentally introduced a duplicate `writeWorktreeMeta` import in lifecycle-service.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ser narrowing
- Derive WebmuxConversationAttachmentPayload from the Zod schema so the
validated boundary and the type can't drift; remove the duplicate interface.
- Gate the linked-worktree Post-to-Linear menu action behind a ConfirmDialog
("Post conversation to ENG-N?") — the endpoint is non-idempotent and a
misclick previously created a duplicate attachment + comment.
- Update LinearPostDialog's inline "looks like an issue id" hint to point at
the new worktree-menu shortcut instead of the CLI fallback.
- Add a second WS reconnect warning at attempt 15 so the user has signal
during the silent window between the first warn (~6s) and the fatal (~60s).
- Reject `--branch` without `--linear` in `parseOneshotArgs` (matches the help
text); the positional branch covers the non-linear path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…er polish
- Track an in-flight `postingLinearBranches` set so the row menu's
"Post conversation to ENG-N" item disables and shows "Posting to
Linear…" while a request is pending; prevents a second click from
firing a duplicate non-idempotent post before the first toast lands.
- Snapshot the linked issue id at click time (`postToLinkedConfirm =
{ branch, issueId }`) so the ConfirmDialog can't render "Post
conversation to ?" if the worktree refreshes away between click
and confirm.
- Drop the unnecessary `async` from `handlePostToLinear` — it only
sets state synchronously now.
- Reorder `parseOneshotArgs` checks: `--branch` with `--resume` now
surfaces a precise message ("Cannot use --branch with --resume")
instead of the misleading "--branch only applies with --linear".
- Tighten `RECONNECT_WARN_AT` to a `readonly [number, number]` tuple
for honest intent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…erge-action # Conflicts: # frontend/src/lib/WorktreeList.svelte
…st.ts
Two cases protecting `handlePostToLinear` / `confirmPostToLinkedIssue`:
- Linked worktree → menu item click opens the ConfirmDialog and "Post"
fires `postWorktreeToLinear` exactly once with
`{ kind: "issue", issueId: <identifier> }`. No `LinearPostDialog`
rendered for linked worktrees.
- In-flight guard → while the post promise is unresolved, the row
menu item disables to "Posting to Linear…" and a second click is
a no-op (`postWorktreeToLinear` call count stays at 1).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`openWorktree`'s contract body was switched from `c.noBody()` to
`OpenWorktreeRequestSchema` (with `prompt?: string`) earlier in this
branch, but `App.svelte` still called `api.openWorktree({ params })`
with no `body`. ts-rest sent no JSON, the server's `req.json()`
threw, and the user got `Invalid JSON` on every reopen.
Pass `body: {}` to match the CLI call site at oneshot.ts:733-736.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a second variant to the Linear label-driven auto-handler. Same poller and master toggle as the existing `webmux` flow; the label chooses the action: - `webmux` (existing) → create a worktree via lifecycleService. - `_oneshot` (new) → spawn `webmux oneshot --linear ENG-X` as a detached child process. Output is dropped — oneshot posts results back to Linear on exit, so the user sees them there. An issue tagged with both labels routes to oneshot (more autonomous of the two). The dispatch is gated on a new `WEBMUX_CLI_ENTRY` env var set by `webmux serve` — if the backend is launched standalone without it, the `_oneshot` path is silently disabled. - backend/src/services/linear-auto-create-service.ts: split filters into webmux-create vs oneshot, accept an optional `runOneshotForIssue` dependency, dispatch both per poll cycle, reuse the single `processedIssueIds` dedupe set. - backend/src/server.ts: build `runOneshotForIssue` from `WEBMUX_CLI_ENTRY`, spawning a detached process pinned to the server's port. `proc.unref()` so it doesn't keep the server alive. - bin/src/webmux.ts: export `WEBMUX_CLI_ENTRY` to the backend's env. - backend/src/__tests__/linear-auto-create-service.test.ts: cover Todo-state + label gating, both-label routing, existing-worktree exclusion, case-insensitive matching. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Moves the "1, 2, 5" responsibilities of oneshot mode (system prompt
injection — already server-side — plus auto-close on agent-done and
Linear post-back) out of the CLI driver and into a server-side
watcher. The CLI now just arms the watcher on create/open and owns
streaming + exit polling for the human watching the terminal.
Effect: a oneshot run can finish safely even if the CLI is killed,
the network drops, or the run was launched by the `_oneshot` Linear
label (no CLI to begin with). Any browser-originated interaction
with the session disarms the watcher so the human can take over.
- contract: `OneshotConfigSchema` carrying `autoCloseOnDone` and
optional `postToLinearOnDone`. Plumbed through CreateWorktree and
OpenWorktree request bodies.
- persisted meta: `WorktreeMeta.oneshot` lives on disk; presence is
the "armed" signal.
- watcher: new `oneshot-watcher-service.ts` polls `source: "oneshot"`
worktrees every 3s; after a 15s idle-grace (or immediately on
stopped/error), fires `postToLinear` then `closeWorktree` and
disarms via `lifecycleService.disarmOneshot`.
- disarm hooks: `apiSendPrompt`, `apiSendAgentsWorktreeMessage`,
`apiInterruptAgentsWorktree`, and `apiUploadFiles` all clear the
oneshot meta. First browser action wins — no race, no duplicate
Linear post.
- CLI trim: drop the in-CLI close + post-back loops from
`runOneshot`. Send `oneshot: {...}` in the create/open body so the
server takes over. Resume path threads the same config through
`openWorktree`.
- label trigger: `runOneshotForIssue` now calls
`lifecycleService.createWorktree({ source: "oneshot", oneshot: {
autoCloseOnDone: true, postToLinearOnDone: ... } })` directly
instead of spawning the CLI. `WEBMUX_CLI_ENTRY` plumbing is gone.
- shared helper: `postWorktreeConversationToLinear` lifted out of
`apiPostWorktreeToLinear` so the watcher can call it without HTTP.
- tests: 8 watcher cases covering idle-grace, immediate terminal,
post-then-close ordering, disarmed-meta skip, autoClose=false,
isActive gate, non-oneshot source skip.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…erge-action # Conflicts: # backend/src/__tests__/linear-auto-create-service.test.ts # backend/src/server.ts # backend/src/services/linear-auto-create-service.ts
Fixes the high-severity items the post-merge review surfaced: - **Watcher silently disabled in most cases.** `startOneshotWatcher` was gated on `hasRecentDashboardActivity`, which is zero for a CLI-only or label-triggered run. Drop the gate; iterating the worktree list every 3s is cheap when no oneshot worktrees exist. - **CLI misclassified user-takeover as failure.** Expose `oneshot` on `ProjectWorktreeSnapshot` (server-side path: meta → reconciled state → runtime → snapshot) so the CLI's polling FSM can detect the watcher's disarm transition. New `onUserTookOver` callback in `pollProjectState` exits cleanly with code 0 and a "user took over" message instead of "agent idle without opening a PR" (1). - **`disarmOneshotIfArmed` now updates runtime state** via the new `ProjectRuntime.setOneshot`, so the next snapshot reflects the disarm without waiting for a reconcile pass. Log level bumped from debug to warn on failure. Plus the easy mediums: - Extract `normalizeOneshotConfig(input)` helper (was duplicated in `apiCreateWorktree` and `apiOpenWorktree`). - Gate `pollConversationHistory` on Claude — Codex has live deltas via WS, polling there was wasted load. - `OneshotPostTarget` now re-exports `PostWorktreeToLinearTarget` from the contract instead of declaring a near-identical type. - Update `getOneshotUsage` to reflect server-driven close + post and the disarm-on-interaction semantics. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- linear-auto-create-service: collapse `filterAutoCreateIssues` and `filterAutoOneshotIssues` onto a shared `filterTriggerableIssues` helper that owns the Todo + processedIssueIds + existing-branch logic. Each public filter now only specifies its label predicate. - conversation-export-service: export `defaultSeedFromLinearDeps` bundling `fetchIssueWithAttachments` + `downloadWebmuxAttachment`. Replaces the inline 3-line record at all three call sites (server.runOneshotForIssue, server.apiCreateWorktree.fromLinear, CLI's --linear path). Test stubs keep using their own deps object. No behavior change. 396 backend + bin tests still pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…close M1: terminal WebSocket input/sendKeys now disarms the oneshot watcher on the first message per connection so the human taking over via the terminal pane doesn't get steamrolled by the auto-close. M2: re-read meta immediately before closeWorktree so a browser-side disarm that lands during the postToLinear HTTP call aborts the close. M3: README documents the `_oneshot` label trigger and its precedence over the `webmux` label. L1: switch the last inline seed-from-Linear deps call site to the shared defaultSeedFromLinearDeps helper. L2: distinguish watcher-driven disarm from a real user takeover in CLI logs by checking whether mux is still alive at the disarm transition. L3: lifecycle endpoints (open without re-arm, close, archive, merge) call disarmOneshotIfArmed so end-of-session actions also clear armed meta. L4: treat agentLifecycle === "closed" as terminal in the watcher so an externally killed agent doesn't leave meta armed indefinitely. L6: expose `oneshot` on the frontend WorktreeInfo and `mapWorktree` so the UI has access to the armed state. L7: oneshot-watcher-service tests now cover postToLinear failure, disarm during postToLinear, closeWorktree throwing, and the `closed` lifecycle path. L8: source-level regression test asserts every disarm reason string the watcher relies on is actually wired in server.ts. L9: tighten WebmuxConversationAttachmentPayloadSchema.agent to AgentIdSchema. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
webmux | 9523e81 | Commit Preview URL Branch Preview URL |
May 13 2026, 04:00 PM |
|
Claude finished @hugocasa's task in 6m 45s —— View job Code Review for PR #238: webmux oneshot + Linear round-tripThorough review of the 59 changed files. Overall this is a solid, well-tested branch — clean separation between the server-side watcher and the CLI streamer, careful disarm wiring, and excellent test coverage (especially
🟠 Concern: fresh-worktree race — watcher could fire on
|
| Severity | Count |
|---|---|
| 🟠 Medium | 1 (fresh-worktree closed race) |
| 🟡 Low | 6 (style + observability) |
| 🟢 Praise | 6 |
The medium-severity concern (closed lifecycle handling) is the only one that could affect runtime correctness. Everything else is polish. Recommend addressing the watcher race before merging since it's a small change in oneshot-watcher-service.ts with the existing test scaffolding ready to cover the fix.
· Branch
|
Claude finished @hugocasa's task in 4m 57s —— View job Code Review — PR #238This is a strong PR. The architectural pivot (server-driven oneshot watcher instead of CLI-driven close/post) is the right move — the CLI is now a thin streamer, and the auto-close + Linear post-back survive Strengths
IssuesMEDIUM — README claim about
|
…e disarm M1: auto-create service evicts processedIssueIds entries whose issue is no longer Todo+labeled, so removing and re-adding the label actually retriggers (matching the README). Tested with the new "re-creates after the label is removed and re-added" case. M2: terminal WS input/sendKeys check projectRuntime.getWorktreeByBranch().oneshot (in-memory, cheap) instead of a per-connection cache. A re-arm on the same WS is now detected without a stale-cache miss. L1: new lifecycle-service test exercises disarmOneshot end-to-end on real on-disk meta — arms, disarms, asserts the oneshot block is gone but profile, allocatedPorts, startupEnvValues, branch are preserved. Also asserts idempotency. L2: oneshot badge tooltip is now "Autonomous run — auto-closes when done" to cover both the `oneshot` CLI and the `_oneshot` Linear label trigger. L3: lifecycle-service.disarmOneshot uses a plain `delete` on a shallow copy instead of the awkward destructure-and-`void` pattern. L4: OneshotWatcherDependencies.lifecycleService narrowed to `Pick<LifecycleService, "closeWorktree" | "disarmOneshot">` so watcher tests can mock without `as unknown as LifecycleService`. L5: comment in apiCreateWorktree's fromLinear fallback clarifying it only fires for dashboard/REST callers (CLI resolves the seed in-process first). NIT: JSON.parse failure in parseClaudeSessionRecords logs at `warn` again so a corrupt session file is visible — the prefix filter at `debug` still drops non-JSON noise silently. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Claude finished @hugocasa's task in 7m 38s —— View job Code Review — PR #238: webmux oneshot + Linear round-tripSolid work overall — the server-driven watcher with race-safe close is the right architecture, and the disarm wiring is well-thought-out. The Inline comments cover the specific files/lines. Summary below. 🟠 MEDIUM — Fresh-worktree race on
|
| Severity | Count |
|---|---|
| 🟠 Medium | 1 (fresh-worktree closed race, still open) |
| 🟡 Low | 5 |
| 🟢 Praise | 6 |
The MED-1 race is the only one I'd insist on before merge — it's a small change in oneshot-watcher-service.ts with the existing test scaffolding ready to cover the fix. Everything else is polish or carry-over from the prior round.
· feat/oneshot-and-on-merge-action
| // `closed` is terminal-without-completion: the agent process is gone (e.g. server | ||
| // restart mid-run, manual exit). Without this, armed meta would persist forever | ||
| // and re-fire as soon as the agent comes back to life. | ||
| const isTerminal = | ||
| agentLifecycle === "stopped" || | ||
| agentLifecycle === "error" || | ||
| agentLifecycle === "closed"; | ||
| const isIdle = agentLifecycle === "idle"; | ||
| if (!isTerminal && !isIdle) { | ||
| state.idleSinceMs = null; | ||
| return; | ||
| } | ||
| if (state.idleSinceMs === null) state.idleSinceMs = now(); | ||
| const stable = isTerminal || now() - state.idleSinceMs >= idleGrace; | ||
| if (!stable) return; |
There was a problem hiding this comment.
🟠 Fresh-worktree race — treating closed as terminal without idle grace can fire close + Linear post-back on an empty conversation for a brand-new oneshot.
lifecycle: "closed" is the default for a freshly upserted worktree (see project-runtime.ts:56 makeDefaultState). Claude only transitions out of closed when the UserPromptSubmit hook fires (agent-runtime.ts:219-222), and the CLI's ensureWorktreeReady returns as soon as mux=true even with status="closed". With the 3s watcher cadence, a slow agent start can hit the first poll while still closed and immediately fire close + post-back on an empty transcript.
The watcher test on L264 (treats closed agent lifecycle as terminal so meta does not stay armed) currently encodes this behavior — which is the right defense against server-restart-mid-run, but it's also the bug for a fresh oneshot. Both prior reviewers raised this; the latest commit only added the closed branch without guarding the startup window.
Suggested fix (smallest change): apply the same idle grace to closed as to idle. A real terminal close stays closed; a startup window resolves to running first.
| // `closed` is terminal-without-completion: the agent process is gone (e.g. server | |
| // restart mid-run, manual exit). Without this, armed meta would persist forever | |
| // and re-fire as soon as the agent comes back to life. | |
| const isTerminal = | |
| agentLifecycle === "stopped" || | |
| agentLifecycle === "error" || | |
| agentLifecycle === "closed"; | |
| const isIdle = agentLifecycle === "idle"; | |
| if (!isTerminal && !isIdle) { | |
| state.idleSinceMs = null; | |
| return; | |
| } | |
| if (state.idleSinceMs === null) state.idleSinceMs = now(); | |
| const stable = isTerminal || now() - state.idleSinceMs >= idleGrace; | |
| if (!stable) return; | |
| // `closed` is terminal-without-completion: the agent process is gone (e.g. server | |
| // restart mid-run, manual exit). Without this, armed meta would persist forever | |
| // and re-fire as soon as the agent comes back to life. BUT `closed` is also the | |
| // default for a freshly upserted worktree (project-runtime.ts:56), and Claude | |
| // only transitions to "running" via UserPromptSubmit — so we apply idle grace | |
| // to `closed` too. A real terminal close stays `closed`; a startup window | |
| // resolves to "running"/"idle" first. | |
| const isTerminalImmediate = | |
| agentLifecycle === "stopped" || | |
| agentLifecycle === "error"; | |
| const needsGrace = | |
| agentLifecycle === "idle" || | |
| agentLifecycle === "closed"; | |
| if (!isTerminalImmediate && !needsGrace) { | |
| state.idleSinceMs = null; | |
| return; | |
| } | |
| if (state.idleSinceMs === null) state.idleSinceMs = now(); | |
| const stable = isTerminalImmediate || now() - state.idleSinceMs >= idleGrace; | |
| if (!stable) return; |
Then add a test asserting closed lifecycle waits the idle grace (analog of the existing waits the idle grace before firing on idle test).
| // Disarm so the watcher doesn't re-trigger on the next poll, even if close | ||
| // didn't fully succeed (e.g. user reopens manually — that interaction would | ||
| // disarm anyway, but the explicit clear here removes the race). | ||
| await deps.lifecycleService.disarmOneshot(branch); |
There was a problem hiding this comment.
🟡 Runtime-state mirror missing when autoCloseOnDone === false
lifecycleService.disarmOneshot(branch) only writes meta to disk — it doesn't update ProjectRuntime.oneshot. When autoCloseOnDone === true, the subsequent closeWorktree call triggers reconciliationService.reconcile(..., { force: true }) which re-reads meta from disk and clears the runtime state. But when autoCloseOnDone === false, no reconcile fires, so ManagedWorktreeRuntimeState.oneshot (and thus WorktreeSnapshot.oneshot) stays non-null until the next regular reconcile pass.
The CLI's pollProjectState watches snapshot.oneshot going armed → null as the "user took over" signal (bin/src/oneshot.ts:510-515). For a --keep-open run that posted to Linear and disarmed, the CLI keeps polling against a stale oneshot: { ... } snapshot until the next reconcile.
Fix: mirror the disarm to runtime state right after the disk write, same way disarmOneshotIfArmed does in server.ts:195-196:
await deps.lifecycleService.disarmOneshot(branch);
const state = deps.projectRuntime.getWorktreeByBranch(branch);
if (state) deps.projectRuntime.setOneshot(state.worktreeId, null);(That needs projectRuntime to add a getWorktreeByBranch to the watcher dep type, or a new disarmRuntimeOneshot(branch) method.)
| baseBranch: state.baseBranch ?? null, | ||
| agent: resolved.worktree.agentName ?? null, | ||
| prUrl, | ||
| conversation: conversationResult.data.conversation, |
There was a problem hiding this comment.
🟡 webmuxVersion is accepted by buildLinearSummaryMarkdown but never passed here.
linear-service.ts:810 accepts webmuxVersion?: string, but exportInput here doesn't supply it — production runs always omit the version from the summary comment. Either drop the field from LinearSummaryInput and ExportConversationInput, or read it from the bin/package.json and thread it through.
Same observation in the prior review round — still unaddressed.
| const forcePrSync = async (): Promise<void> => { | ||
| try { | ||
| const refreshed = await api.syncWorktreePrs({ params: { name: branch } }); | ||
| recordPrEvents(state, refreshed, callbacks.onPrEvent); | ||
| } catch { | ||
| // Sync may transiently fail (network/server) — fall back to whatever we already saw. | ||
| } | ||
| }; |
There was a problem hiding this comment.
🟡 forcePrSync silently swallows errors — misleading "agent stuck" exit.
If the agent opened a PR but this one-shot sync HTTP call fails (rate limit, network blip), state.seenPrUrls stays empty and the CLI prints "agent stuck without opening a PR" and exits 1. A stderr log on the catch would surface the real cause without breaking the fall-back flow:
| const forcePrSync = async (): Promise<void> => { | |
| try { | |
| const refreshed = await api.syncWorktreePrs({ params: { name: branch } }); | |
| recordPrEvents(state, refreshed, callbacks.onPrEvent); | |
| } catch { | |
| // Sync may transiently fail (network/server) — fall back to whatever we already saw. | |
| } | |
| }; | |
| const forcePrSync = async (): Promise<void> => { | |
| try { | |
| const refreshed = await api.syncWorktreePrs({ params: { name: branch } }); | |
| recordPrEvents(state, refreshed, callbacks.onPrEvent); | |
| } catch (err) { | |
| const msg = err instanceof Error ? err.message : String(err); | |
| callbacks.onPrEvent(`PR sync failed (will rely on prior snapshot): ${msg}`); | |
| } | |
| }; |
Same observation as the prior review round.
| while (Date.now() < deadline) { | ||
| try { | ||
| const response = await api.fetchWorktrees(); | ||
| const worktree = response.worktrees.find((w: ProjectWorktreeSnapshot) => w.branch === branch); | ||
| if (worktree && worktree.mux && worktree.status !== "creating") { | ||
| return { ready: true, worktree }; | ||
| } | ||
| } catch { | ||
| // ignore | ||
| } | ||
| await new Promise((resolve) => setTimeout(resolve, 500)); | ||
| } | ||
| stderr(`[${timestamp()}] [error] timed out waiting for ${branch} session to start`); | ||
| return { ready: false }; | ||
| } |
There was a problem hiding this comment.
🟡 ensureWorktreeReady returns at status="closed" — the CLI starts polling before the agent is alive.
The loop exits as soon as mux=true && status !== "creating". For a fresh oneshot, status is the runtime lifecycle, which defaults to "closed" (per project-runtime.ts:56) until Claude's UserPromptSubmit hook flips it to "running". Two implications:
- Combined with the watcher's "closed-is-terminal" handling (
oneshot-watcher-service.ts:67-72), there's a window where the watcher can fire close + post-back on an empty conversation between create and the first hook event. See the related inline on the watcher. - Even with the watcher fixed, the CLI shows nothing in that 1-3s window. A "waiting for agent to start" stderr line would close the silence gap.
Consider also waiting for status to be one of "running" | "idle" (not "closed") before returning ready, so the CLI's poll state and the watcher's poll state line up on the same "agent alive" signal.
M1: the previous "closed = terminal" patch was wrong — `closed` is also the default lifecycle for a freshly upserted worktree before the agent's first event arrives. Treat it like `idle` (needs the 15s idle grace) so a cold-start session that resolves to running/idle within the grace window survives, while a genuine post-run close still fires after the grace expires. Replaces the "closed = immediate fire" test with two cases that cover both behaviors. L1: mirror the watcher's `disarmOneshot` to `ProjectRuntime` so the snapshot reflects the cleared armed state immediately. Without this, when `autoCloseOnDone=false` no close-driven reconcile fires and `snapshot.oneshot` stays armed in memory, defeating the CLI's "user took over" detector. L2: `postWorktreeConversationToLinear` now passes `pkg.version` as `webmuxVersion` so Linear summary comments include the producing CLI version. L3: `forcePrSync` in the CLI poller now logs a warning to stderr on failure instead of swallowing — a network blip during the post-stable sync was indistinguishable from "agent stuck without opening a PR". L4: `ensureWorktreeReady` now waits for status to leave `closed`, not just `creating`. Pairs with M1 — the CLI no longer starts polling during the cold-start window the watcher is now correctly ignoring. NIT: comment on `consecutiveClosedReadings >= 2` explains the ~3-6s lag is a deliberate trade-off against false-positive close events on reconcile gaps. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Claude finished @hugocasa's task in 6m 49s —— View job Code Review — PR #238Verdict: The MED-1 fresh-worktree race and all four LOW items from the prior round are properly addressed in
Verification of prior-round findings
🟡 LOW-1 —
|
| Severity | Count |
|---|---|
| 🟠 Medium | 0 |
| 🟡 Low | 2 (--resume cold-start exclusion, runaway _oneshot retries) |
| ⚪ Nit | 1 |
| 🟢 Praise | 6 |
LGTM to merge once LOW-1 is addressed (or explicitly accepted as out-of-scope); LOW-2 can land as a follow-up. The architectural pivot from CLI-driven to server-driven oneshot is solid, the disarm wiring is exhaustive and self-checking, and the watcher's race-safe close has the test coverage to back it up.
· feat/oneshot-and-on-merge-action
A bare `_oneshot` is too generic and easy to collide with unrelated team conventions; prefixing with `webmux_` makes ownership obvious at a glance and matches the `webmux` companion label. No behavior changes — same case-insensitive match, same precedence over the plain `webmux` label. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Opening a closed worktree (the dashboard's "Enter" / Open button on a stopped session) is "let me peek at the agent's progress", not "I'm taking over". The disarm should fire only on actual interaction — terminal input, chat send, upload, etc. — which already happen via their respective endpoints. Drops the `open-worktree` disarm + the corresponding wiring-test entry. Close, archive, and merge still disarm (those are decisive end-of-run actions). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Claude finished @hugocasa's task in 5m 40s —— View job Code Review — PR #238 (round 5)Reviewing the new commits since round 4: This round I have no merge-blockers — just a few small notes, mostly carry-overs and one new edge case in the
Verification of prior-round findings
🟡 LOW-1 —
|
| Severity | Count |
|---|---|
| 🔴 High | 0 |
| 🟠 Medium | 0 |
| 🟡 Low | 3 (open-disarm edge, --resume corner, runaway _oneshot retries) |
| ⚪ Nit | 1 (watcher grace tuning) |
| 🟢 Praise | 7 |
LGTM to merge. The three LOW items are edge cases — none of them affect the happy path. LOW-1 (4dfdda0 peek-doesn't-disarm) is worth a quick UX call before merge; the others can land as follow-ups. The architectural pivot from CLI-driven to server-driven oneshot watcher is solid, the disarm wiring is exhaustive and self-checking, and the round-4 MED-1 fix has the test coverage to prevent regressions.
· Branch
… failures L2: `webmux oneshot --resume <branch>` without `--prompt` was silently broken for Claude — `claude --continue` doesn't fire UserPromptSubmit, so lifecycle stays "closed" and the CLI times out at 60s with exit 1. Codex worked because `codex resume --last` fires SessionStart unconditionally. The parser now rejects this combination with a clear error pointing at the dashboard for re-attach-without-prompting. L3: linear-auto-create now adds the issue id to `processedIssueIds` on failure as well as success. Without this, a permanent error (e.g. "Branch already exists" for an out-of-band local branch) would retry every 60s forever. The label-eviction pass still lets the user retrigger by removing and re-adding the label after fixing the underlying issue. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Summary
This branch lands the
webmux oneshotcommand and a server-driven oneshot watcher that auto-closes the session and posts the agent's conversation back to the linked Linear issue when the run completes. It also adds a Linear_oneshotlabel trigger so a labeled Todo issue spins up an autonomous run end-to-end.Re-opening after closing #230 for a critical self-review + dedup + one more thorough review round (see commit history for the receipts).
Headline behaviors
webmux oneshot— autonomous agent run on a fresh worktree, exits when terminal (PR opened = success, idle without PR = stuck). One--linearflag with auto-detect.oneshot-watcher-serviceon the backend, not in the CLI. The CLI is a thin streamer that exits cleanly when the server disarms._oneshotlabel — Linear issues tagged_oneshottrigger an autonomous run;_oneshotwins overwebmuxwhen both are present. Documented in the README.Architecture notes
OneshotConfigwire shape onCreateWorktree+OpenWorktreearms the watcher.WorktreeMeta.oneshotpersists on disk;ManagedWorktreeRuntimeState.oneshot+WorktreeSnapshot.oneshotmirror it so the CLI sees disarm without a reconcile round-trip.closeWorktreeto close a race where the user disarms during the Linear post-back HTTP call.input/sendKeysper connection (avoids per-keystroke disk reads).agentLifecycle === "closed"is terminal, so a crash or server restart can't leave meta armed forever.Test plan
bun run --cwd backend test— 303 pass / 0 failbun test bin/src— 107 pass / 0 failbun test packages/api-contract/src— 4 pass / 0 failbun run --cwd frontend test— 73 pass / 0 fail (vitest)bun run --cwd backend check(tsc) — cleanbun run --cwd frontend check(svelte-check) — clean--linearand let it go idle — confirm the conversation is posted to the Linear issue and the session auto-closes._oneshot— confirm the auto-create poller spins up an autonomous run.Review trail
This branch went through eight rounds of review on #230 before being closed for a critical self-review + dedup + one final review round (see the
refactor: address PR review #Ncommits). The reviewers ran by ultrareview caught: a HIGH-severity watcher gating bug (silently disabled by anisActivepredicate, fixed in self-review), missing terminal-input disarm path (M1), a postToLinear/close race (M2), undocumented_oneshotlabel (M3), and 8 LOW findings — all addressed in the final commit.🤖 Generated with Claude Code