From 84c7aa0b71dc2b8cfaf459181eaaf98e3c3de5ca Mon Sep 17 00:00:00 2001 From: Yifei Chen Date: Thu, 13 Aug 2026 09:10:21 +0000 Subject: [PATCH 1/3] fix(daemon): un-pinned @mention wakes respect the agent's pinned cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the un-pinned `mentioned` wake onto the same residual cwd-resolution ladder as `task_assigned`: add RESIDUAL_CWD_UPGRADE_TRIGGERS (the autonomous idea-anchored family plus `mentioned`) and gate the step-4 idea-session-origin upgrade and step-4a agent-owner project-pin fallback on it. The additions fire only on the residual online_first case — mention.service already pre-resolves explicit / direct-idea-instance / mentioner-owner pins at creation time — so an un-pinned @mention (incl. an agent->agent return-@mention) lands where that idea's conversation lives or on the target agent's owner-pinned cwd instead of a random online cwd. human_instruction / resource_resumed stay excluded; explicit-pin mentions still short-circuit as HARD pins. Idea e40f2b2c (Chorus 0.16.1). Confined to notification-turn.ts; no schema, migration, permission, endpoint, or new transport. 202 wake-service tests green, tsc clean. Live Codex e2e (task 3) handed to human (needs merge+deploy). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../.openspec.yaml | 2 + .../mention-wake-respect-pinned-cwd/README.md | 3 + .../mention-wake-respect-pinned-cwd/design.md | 176 ++++++++++++++++++ .../proposal.md | 85 +++++++++ .../daemon-cwd-instance-addressing/spec.md | 140 ++++++++++++++ .../specs/project-cwd-anchoring/spec.md | 35 ++++ .../mention-wake-respect-pinned-cwd/tasks.md | 20 ++ .../__tests__/notification-turn.test.ts | 146 ++++++++++++++- src/services/notification-turn.ts | 47 ++++- 9 files changed, 636 insertions(+), 18 deletions(-) create mode 100644 openspec/changes/mention-wake-respect-pinned-cwd/.openspec.yaml create mode 100644 openspec/changes/mention-wake-respect-pinned-cwd/README.md create mode 100644 openspec/changes/mention-wake-respect-pinned-cwd/design.md create mode 100644 openspec/changes/mention-wake-respect-pinned-cwd/proposal.md create mode 100644 openspec/changes/mention-wake-respect-pinned-cwd/specs/daemon-cwd-instance-addressing/spec.md create mode 100644 openspec/changes/mention-wake-respect-pinned-cwd/specs/project-cwd-anchoring/spec.md create mode 100644 openspec/changes/mention-wake-respect-pinned-cwd/tasks.md diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/.openspec.yaml b/openspec/changes/mention-wake-respect-pinned-cwd/.openspec.yaml new file mode 100644 index 00000000..b6b2d1f6 --- /dev/null +++ b/openspec/changes/mention-wake-respect-pinned-cwd/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-13 diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/README.md b/openspec/changes/mention-wake-respect-pinned-cwd/README.md new file mode 100644 index 00000000..aa4bcff8 --- /dev/null +++ b/openspec/changes/mention-wake-respect-pinned-cwd/README.md @@ -0,0 +1,3 @@ +# mention-wake-respect-pinned-cwd + +Un-pinned @mention wakes resolve cwd via the same ladder as task_assigned (idea session-origin upgrade + project-owner-pin fallback) diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/design.md b/openspec/changes/mention-wake-respect-pinned-cwd/design.md new file mode 100644 index 00000000..b35d432e --- /dev/null +++ b/openspec/changes/mention-wake-respect-pinned-cwd/design.md @@ -0,0 +1,176 @@ +# Technical Design: Un-pinned @mention wakes respect the agent's pinned cwd + +## Overview + +This change flips one deliberately-specced invariant — "un-pinned `mentioned` +wake → raw online-first" — so that an un-pinned `@mention` walks the same +cwd-resolution ladder as `task_assigned`. The whole behavior lives in one +server-side chokepoint; there is no new transport, schema, or endpoint. + +## Current-behavior audit (the research deliverable) + +All wake-target resolution happens in `createTurnAndResolveTarget` +(`src/services/notification-turn.ts`). The resolution ladder, in priority order: + +1. **`resolvePinnedTarget(ctx, trigger)`** returns a HARD pin if one applies: + pre-resolved activity cwd snapshot → temporary host/cwd → **explicit mention + pin** (host+cwd parsed from mention markup) → **task instance override** → + **own-idea instance pin** → **root-idea inheritance** (same-agent guard). +2. **`selectOriginConnection(connections, pin)`** matches a hard pin to an + ONLINE `DaemonConnection` by strict `(host, cwd)` equality, classifying the + result as `directed` / `offline_pin` / `online_first` / `none`. An offline + HARD pin is notify-only, never re-routed (the deliberate MR #354 reversal). +3. **Idea session-origin upgrade** (`resolveIdeaSessionOriginTarget`): only when + the selection is still `online_first`, re-point the wake to the idea's + existing `DaemonSession.originConnectionUuid` when that connection is online. +4. **Project-owner-pin fallback** (`resolveProjectOwnerCwdPin`): only when still + `online_first`, read the agent OWNER's `ProjectAgentCwdPreference` for + `(ownerUuid, projectUuid, agentUuid)` and re-select against it as a HARD pin. + +Net precedence: **instance pin → online idea session-origin → project-owner pin +→ raw online-first.** + +### Trigger classification + +`NOTIFICATION_ACTION_TO_TURN_TRIGGER` collapses notification actions into a +small set of `TurnTrigger` values. Two behavior classes for cwd resolution: + +| Class | Triggers | Steps 3 & 4 (upgrade + owner-pin) | +|---|---|---| +| **Upgrade-eligible** | `task_assigned` (collapse target of `proposal_approved`/`proposal_rejected`/`idea_claimed`/`task_verified`/`task_reopened`), `elaboration`, `elaboration_verified`, `start_development`, `yolo_requested` | **Applied** | +| **Excluded** | `mentioned`, `human_instruction`, `resource_resumed` | **Skipped** | + +The `mentioned` exclusion is the audited defect. `human_instruction` is +correctly excluded (its exact target + `deliver_turn` are resolved by the +instruction send path — including it would double-deliver). `resource_resumed` +is a synthetic control-channel dispatch that is never persisted, so it never +reaches this chokepoint. + +### Two-pin verification (Q3) + +The two pins are distinct models at distinct tiers, confirmed to both be honored +for `task_assigned` and both skipped for un-pinned `mentioned`: + +- **Instance pin** — `AgentInstance` (`(agent, host, cwd)`), reached via an + `agent_instance` assignee. Highest priority (step 1). Already honored for a + `mentioned` wake **when the mention carries an explicit pin** or inherits the + root-idea instance pin (the `resolvePinnedTarget` mention branch) — so this + tier is not the gap. +- **Project-owner pin** — `ProjectAgentCwdPreference`, reached via step 4. This + is skipped for `mentioned` today — part of the gap. + +### Return-wake finding (Q6 follow-up) + +- The woken agent **does** know who woke it: the notification carries + `actorType` / `actorUuid` / `actorName`, and the wake prompt (`cli/prompts.mjs`) + injects `@[actorName](actorType:actorUuid)`; the `task_assigned` prompt + instructs the agent to @mention the assigner back on completion. +- There is **no** dedicated return-wake: `submit_for_verify` / `report_work` are + non-wake actions, and the assigner is not a recipient of the submit event. The + only working return path is the manual @mention — which is exactly the path + this change fixes. Hence no separate return-wake mechanism is built (owner + decision Q7 = minimal). + +## Architecture (the fix) + +Anchor a `mentioned` wake on its **root Idea** and let it enter steps 3–4: + +- **Root-idea anchor.** A mention targets any entity (comment/idea/proposal/ + task/document). Resolve its root Idea with the shared root-idea resolver (the + same resolver the existing mention-pin inheritance already uses for the + comment's root Idea). If there is no root Idea, steps 3–4 have no idea anchor + and the wake falls through to step 4's project lookup (by the mention target's + project) and then online-first. +- **Session-origin upgrade (step 3) for `mentioned`.** Applies only when the + mentioned agent is the root Idea's assignee agent (its conversation lives on + that idea) and that session origin is online — mirroring the existing + mention-pin inheritance's "mentioned agent IS the root Idea's assignee agent" + distinction. When the mentioned agent is a *different* agent (or no root Idea), + the idea's session belongs to another agent and step 3 does not resolve a + target for this agent → fall through to step 4. +- **Project-owner-pin fallback (step 4) for `mentioned`.** The mentioned agent's + owner's `ProjectAgentCwdPreference` for `(project of the mention target, that + agent)`, applied as a hard pin exactly as for `task_assigned`. + +Implementation is confined to the trigger-classification sets and the mention +branch's idea-anchor resolution in `notification-turn.ts`. When step 3 or 4 +resolves a target, the existing directed-delivery path (`deliver_turn` + +broadcast-suppression + cross-cwd session re-point) fires unchanged — the same +machinery `task_assigned` already uses. + +## Module contracts + +- **cwd precedence is unchanged** — this change does not reorder the ladder; it + only removes `mentioned` from the two exclusion sets and supplies its idea + anchor. `human_instruction` / `resource_resumed` stay excluded. +- **Explicit mention pin unchanged** — a mention markup pin is resolved at step 1 + and short-circuits steps 3–4 as today. +- **No-pin / no-session preservation** — an un-pinned mention of an agent with no + live idea session and whose owner has no project pin resolves to raw + online-first, byte-for-byte as before. This is the regression guard the tests + must lock. + +## Verification strategy + +1. **Unit / integration** (hard gate): table-driven tests over every + `NOTIFICATION_ACTION_TO_TURN_TRIGGER` value × {instance pin, online + session-origin, project-owner pin, none}. New assertions for un-pinned + `mentioned`: session-origin upgrade when the mentioned agent owns the root + idea's live session; project-owner-pin fallback otherwise; raw online-first + when neither applies. Regression assertions: `task_assigned` unchanged, + explicit-pin mention unchanged, `human_instruction` still excluded. +2. **Live e2e with Codex** (empirical, owner-authorized). The daemon serving + this session cannot be restarted to reconfigure multi-agent mode (it would + kill the session), so the e2e uses a **separate test daemon** serving both + Claude and Codex. Steps: pin Codex at the idea and project level to a chosen + cwd; (a) assign a task to Codex and confirm the wake lands in the pinned cwd; + (b) `@mention` Codex (un-pinned mention) and confirm the wake lands in the + pinned cwd — the primary fix; (c) have Codex @mention the assigner back and + confirm the return-wake lands in the assigner's pinned cwd. Evidence = + daemon logs / session transcript showing the landed cwd per case. If the + live wake cannot be fully closed headlessly, capture the evidence and hand + final sign-off to the human. + +## Risks & mitigations + +- **Wrong-agent session-origin.** Routing a mention to the root idea's session + when that session belongs to a *different* agent would target the wrong + daemon. Mitigation: the step-3 guard requires the mentioned agent to be the + root idea's assignee (its own session), matching the existing mention-pin + inheritance rule; otherwise fall through to step 4. +- **Broadening the "autonomous wake" surface.** The project-owner-pin fallback + spec says "autonomous server-minted wakes"; a human-triggered mention is + arguably not "autonomous." Mitigation: the spec delta explicitly names the + un-pinned `mentioned` wake as in-scope for the fallback, removing the + ambiguity. +- **Offline pin semantics.** A resolved project-owner/instance pin that is + offline stays notify-only (the MR #354 reversal), inherited unchanged — a + mention to an offline pinned cwd is NOT re-routed. Tests assert this. + +## Audit correction (post-approval, from task 1) + +Implementing task 1's audit refined the premise this proposal opened with. Two +corrections, now reflected in the code comments and the shipped tests: + +1. **An un-pinned `@mention` is NOT raw online-first today.** `mention.service` + (`createMentions` → `resolveMentionTarget` → `resolveProjectAgentCwdTarget`) + already resolves, at notification-creation time, both the direct idea's + `agent_instance` pin (threaded as `pinnedHost/pinnedCwd`) and the + **mentioner-owner's** `ProjectAgentCwdPreference` (threaded as + `resolvedCwdSource="project_fixed"`). Both become HARD pins in + `resolvePinnedTarget` (lines 347 / 371) → `directed` / `offline_pin`. So the + fix's step-4 (idea session-origin) and step-4a (project-owner-pin) additions + are purely **additive** — they fire ONLY in the residual `online_first` case + (no explicit pin, not the direct-idea instance assignee, and the + mentioner-owner had no project pref). The two genuine gaps closed are: the + **idea session-origin upgrade** for `mentioned`, and a **target-agent-owner** + project-pin fallback (step 4a) for the case where mention.service's + mentioner-owner lookup found nothing. + +2. **No new comment→root-idea resolver hop is needed** (the proposal-reviewer's + NOTE turned out moot for the common case). `mention.service` rewrites a + comment mention's `entityType`/`entityUuid` to the comment's TARGET + (idea/task/proposal/document — all lineage-walkable), so `directIdeaUuid` + already resolves via the existing step-3a lineage walk. Only the + deleted-comment edge (target lookup missing) leaves `entityType: "comment"`, + which degrades gracefully to online-first. diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/proposal.md b/openspec/changes/mention-wake-respect-pinned-cwd/proposal.md new file mode 100644 index 00000000..b48ce734 --- /dev/null +++ b/openspec/changes/mention-wake-respect-pinned-cwd/proposal.md @@ -0,0 +1,85 @@ +# Un-pinned @mention wakes respect the agent's pinned cwd + +## Why + +In multi-agent mode (one daemon serving N independent agents), an agent can be +pinned to a working directory at two levels: an **idea/task instance pin** +(`assigneeType = "agent_instance"`, a durable `(agent, host, cwd)`) and a +**project-owner pin** (`ProjectAgentCwdPreference`, a per-user/project/agent +fixed cwd). When one agent wakes another — by assigning a task, or by +`@mention`ing it in a comment — the woken session must land in the cwd the pin +names, not an arbitrary online instance. + +An audit of the server-side wake chokepoint (`createTurnAndResolveTarget` in +`src/services/notification-turn.ts`) found the two wake families are **not** +symmetric: + +- A **`task_assigned`** wake (also the collapse target of `proposal_approved`, + `proposal_rejected`, `idea_claimed`, `task_verified`, `task_reopened`) walks + the full cwd-resolution ladder: **instance pin → online idea session-origin → + project-owner pin → raw online-first**. It respects both pins. +- An **un-pinned `@mention`** wake is a broadcast that resolves to **raw + online-first**. It is *deliberately excluded* from both the idea + session-origin upgrade and the project-owner-pin fallback + (`daemon-cwd-instance-addressing` and `project-cwd-anchoring` both encode this + exclusion). So an `@mention` of an agent that is pinned only at the + project/idea level (not via an explicit in-mention pin) wakes an **arbitrary + online cwd** — the same class of "random cwd" defect that the proposal-wake + session-origin fix (`fix-proposal-wake-session-origin`, MR #381) and the + project-owner-pin fallback (`daemon-multi-agent-project-polish`, MR #484) + closed for the `task_assigned` family. + +This asymmetry has a second, higher-stakes consequence. There is **no dedicated +return-wake** mechanism: when a woken developer agent B finishes and wants to +notify the agent A that delegated the task, the only path is B **manually +@mentioning A** (the `task_assigned` wake prompt explicitly instructs this). +That return-@mention rides the exact un-pinned-`@mention` path above — so today +it wakes A at a random online cwd instead of A's pinned working directory. +**Fixing the `@mention` gap is therefore also what makes agent→agent +return-wakes land in the correct cwd.** + +## What Changes + +Bring the un-pinned `mentioned` wake onto the **same** cwd-resolution ladder as +`task_assigned`: + +1. An un-pinned `mentioned` wake, anchored on the **mention's root Idea** + (resolved via the shared root-idea resolver), SHALL receive the **idea + session-origin upgrade** when that root Idea has an existing online session + origin for the mentioned agent. +2. When no higher-priority step resolves a target, an un-pinned `mentioned` wake + SHALL receive the **project-owner-pin fallback** — the mentioned agent's + owner's `ProjectAgentCwdPreference` for the `(project, agent)` pair. +3. When neither applies, the wake falls back to raw online-first **exactly as + today** — no regression for agents/ideas with no pin and no live session. + +Explicitly **unchanged**: a `@mention` that carries an explicit `(host, cwd)` +pin in its markup still resolves as a hard pin (highest priority); a +`human_instruction` wake is still excluded (its target is resolved by the +instruction send path); `resource_resumed` is a synthetic control dispatch that +never reaches this chokepoint. No dedicated task-completion return-wake is added +(the fixed `@mention` path is sufficient — see design §Return-wake). + +## Capabilities + +- **daemon-cwd-instance-addressing** (MODIFIED) — the idea session-origin + upgrade family now includes the un-pinned `mentioned` wake; the pinned/online- + first base requirement's mention scenario is narrowed to the no-upgrade case. +- **project-cwd-anchoring** (MODIFIED) — the autonomous project-owner-pin + fallback explicitly includes the un-pinned `mentioned` wake. + +## Impact + +- **Code:** `src/services/notification-turn.ts` — the trigger-classification + sets that exclude `mentioned` from the session-origin upgrade and the + project-owner-pin fallback, plus the root-idea anchor resolution for a + mention. No other transport is added — the change reuses the existing + directed-delivery (`deliver_turn` / broadcast-suppression) machinery. +- **No** database schema change, migration, new permission bit, new endpoint, + or new picker. +- **Verification:** unit/integration tests for the new mention resolution plus + regression tests pinning the unchanged behavior, and a real multi-agent + live e2e using a separate daemon serving Claude + Codex (owner-authorized). +- **Behavioral risk:** an un-pinned `@mention` that previously woke an arbitrary + online cwd will now be directed. This is the intended fix; the no-pin / + no-session case is preserved so agents without any pin see no change. diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/specs/daemon-cwd-instance-addressing/spec.md b/openspec/changes/mention-wake-respect-pinned-cwd/specs/daemon-cwd-instance-addressing/spec.md new file mode 100644 index 00000000..666f2798 --- /dev/null +++ b/openspec/changes/mention-wake-respect-pinned-cwd/specs/daemon-cwd-instance-addressing/spec.md @@ -0,0 +1,140 @@ +## MODIFIED Requirements + +### Requirement: The autonomous wake SHALL honor a pinned cwd and fall back to online-first + +When a `task_assigned` or `mentioned` notification wakes an agent, the connection-selection step SHALL honor a pinned target instance if one was recorded with the trigger: it SHALL resolve the `DaemonConnection` matching the pinned `(agentUuid, host, cwd)` AND being ONLINE, and pin the session origin to it. A trigger with NO pin SHALL fall back to the existing online-first connection selection, exactly as before this change. A PINNED trigger whose pin matches NO online connection (the pinned instance is offline, or the place is not registered) SHALL create NO turn and SHALL wake NO instance: the already-recorded notification SHALL stand as the plain record (notify-only) — the wake SHALL NOT silently fall back to a different online instance, because routing a pinned wake to a cwd the user did not choose is the user-visible defect this change fixes. When the agent has NO online connection at all (pinned or not), the wake likewise SHALL create no turn and the notification SHALL stand as the plain record. There is no durable queue or backfill that holds a pinned turn until its instance comes online. The wake SHALL NOT infer a cwd from the project under any circumstance. + +Beyond selecting the origin connection, the LIVE wake for a PINNED `task_assigned` or `mentioned` notification SHALL be DIRECTED so that only the daemon at the resolved online `(host, cwd)` instance wakes and answers. The server SHALL emit a `deliver_turn` control ping on the resolved target connection's `control:{connectionUuid}` channel carrying the created turn's precise `turnUuid`, reusing the existing reverse-control / pending-turn machinery that already delivers `human_instruction` turns. Because the notification SSE stream is per-agent (every online connection of the agent receives the same `new_notification`), the resolved target connection SHALL ALSO be communicated to the daemon as transport-only data on the notification the daemon reads (NOT a persisted column) so that each daemon can compare it to its own registered connection identity: a daemon whose own connection identity is NOT the resolved target SHALL suppress the broadcast wake for that pinned notification; the daemon whose connection identity IS the target SHALL wake (and the target's broadcast copy and `deliver_turn` delivery SHALL collapse to exactly one wake via the shared dedup set). A wake that resolves to NO target (a `task_assigned`/`mentioned` wake for which no pin, no online idea session-origin, and no project-owner pin resolved a target, or a pinned/offline wake for which no turn was created) SHALL behave exactly as before this change — no `deliver_turn` is emitted, no suppression occurs, and an un-pinned broadcast wakes the online-first daemon. A daemon that has not yet learned its own connection identity (before the SSE handshake completes) SHALL treat a targeted wake as "not mine" and suppress it, relying on the `deliver_turn` delivery to the actual target and the reconnect pending-turn backfill. The directed-delivery transport SHALL reuse the existing reverse control channel and pending-turn machinery (`control:{connectionUuid}` / `deliver_turn` / the connection-scoped pending-turns read); it SHALL NOT add a new transport, a new permission bit, or a schema migration. + +For the session business key, a DIRECTED idea-anchored wake SHALL keep ONE conversation per idea per agent (`sessionId === directIdeaUuid`) and SHALL NOT fork a per-instance session. When the resolved online origin connection differs from the idea's existing canonical session origin (the cross-cwd case), the wake SHALL RE-POINT that canonical session's `originConnectionUuid` to the resolved online origin and create the turn on the SAME session row, so the user's turn and the daemon's transcript/turn-lifecycle reports land on the same conversation. This re-point is the second — and only other — deliberate, companyUuid-scoped reversal of the write-once `originConnectionUuid` invariant, alongside the explicit `repointSessionOriginAndSend` send path; the autonomous wake SHALL NOT create a `${directIdeaUuid}::${connectionUuid}` per-instance session. Re-pointing is safe because the daemon probes the on-disk transcript per-cwd and starts a fresh session in a new cwd rather than failing `claude --resume`; prior turns remain as read-only history on the same row. This re-point SHALL NOT add a schema migration, a new column, or a new endpoint. + +#### Scenario: A pinned online instance is honored at wake time + +- **GIVEN** a `task_assigned` notification whose assignment pinned `(Laptop-Q3, dev/chorus)` +- **AND** an ONLINE connection matching that `(agent, host, cwd)` exists +- **WHEN** the wake selects a connection +- **THEN** it MUST pin the session origin to that matching online connection rather than the first online connection + +#### Scenario: A pin matching an offline instance wakes no instance (notify-only) + +- **GIVEN** a `mentioned` (or `task_assigned`) wake whose pin matches an OFFLINE connection while another instance of the same agent is online +- **WHEN** the wake resolves its target +- **THEN** it MUST create no turn and wake NO instance (the already-recorded notification stands as the plain record) +- **AND** it MUST NOT silently fall back to the other online instance (routing to an unchosen cwd is the defect being fixed) +- **AND** the pinned turn MUST NOT be queued or backfilled to wait for the pinned instance to come online + +#### Scenario: An unpinned wake uses online-first as before + +- **GIVEN** a wake notification that carries no pinned instance +- **WHEN** the wake selects a connection +- **THEN** it MUST select the first online connection exactly as before this change, with no cwd inference from the project + +#### Scenario: Only the pinned daemon wakes when two instances are online + +- **GIVEN** an agent with two ONLINE instances, `(Laptop-Q3, dev/ai-pm)` and `(Laptop-Q3, dev/strands)` +- **AND** a `mentioned` notification pinned to `(Laptop-Q3, dev/ai-pm)` +- **WHEN** both daemons receive the agent-wide `new_notification` broadcast +- **THEN** only the `dev/ai-pm` daemon MUST wake and answer +- **AND** the `dev/strands` daemon MUST suppress the wake because it is not the resolved target + +#### Scenario: An un-pinned mention with no applicable upgrade still wakes the online-first daemon + +- **GIVEN** an agent with two online instances +- **AND** a `mentioned` notification that carries no pin, whose mentioned agent is NOT the root Idea's assignee (so no idea session-origin applies) and whose owner has no project cwd preference for that project +- **WHEN** both daemons receive the broadcast +- **THEN** the wake MUST proceed exactly as before this change (no target is stamped, so no suppression occurs and the online-first daemon answers) + +#### Scenario: A daemon that has not yet registered suppresses a targeted wake + +- **GIVEN** a pinned `mentioned` notification carrying a resolved target connection +- **AND** a daemon that has not yet learned its own connection identity (handshake incomplete) +- **WHEN** that daemon receives the broadcast +- **THEN** it MUST treat the targeted wake as "not mine" and suppress it +- **AND** delivery MUST rely on the precise reverse-channel delivery / reconnect pending-turn backfill to the actual target + +#### Scenario: A cross-cwd directed idea wake re-points the canonical session instead of forking + +- **GIVEN** an idea whose existing daemon session origin is the ONLINE-or-offline instance `(Laptop-Q3, dev/ai-pm)` and whose session has `sessionId === directIdeaUuid` +- **AND** a directed idea-anchored wake (e.g. a pinned `task_assigned`, or a `human_instruction` resolved to a different instance) resolves to a different ONLINE instance `(Laptop-Q3, dev/strands)` +- **WHEN** the wake creates the turn +- **THEN** it MUST re-point the SAME canonical session's `originConnectionUuid` to `(Laptop-Q3, dev/strands)` and create the turn on that same session row (keeping `sessionId === directIdeaUuid`, `directIdeaUuid` non-null) +- **AND** it MUST NOT create a `${directIdeaUuid}::${connectionUuid}` per-instance session +- **AND** the user's turn and the daemon's later transcript/turn-lifecycle reports MUST land on the same conversation so the running turn is interruptible from the thread the user is viewing + +### Requirement: The proposal-writing wake SHALL be directed to the idea's existing session origin + +An autonomous, idea-anchored wake SHALL be directed to the daemon instance where that idea's conversation already lives — its existing `DaemonSession.originConnectionUuid` for the idea-anchored session (`sessionId === directIdeaUuid`) — rather than fanning out to an arbitrary online instance of the agent, whenever the connection selection would otherwise fall to agent-overall online-first. This direction SHALL apply to the autonomous idea-anchored trigger family: the elaboration-resolve / "Verify Elaborate" handoff wake (`elaboration_verified`), the proposal-review wakes (`proposal_approved` and `proposal_rejected`), the idea-claimed wake (`idea_claimed`), the elaboration request/answer wakes (`elaboration_requested` / `elaboration_answered`), and the task-assignment wakes (`task_assigned` / `task_verified` / `task_reopened`) — every wake that resolves to an Idea anchor and is not already pinned. It SHALL ALSO apply to an un-pinned `mentioned` wake, anchored on the mention's **root Idea** (resolved via the shared root-idea resolver), but ONLY when the mentioned agent is that root Idea's assignee agent — i.e. the idea's existing session belongs to the mentioned agent. When the mentioned agent is a different agent, or the mention has no root Idea, this upgrade SHALL NOT apply (the idea's session origin belongs to another agent) and resolution SHALL fall through to the lower-priority steps (project-owner pin, then online-first). A `mentioned` wake that carries an explicit `(host, cwd)` pin SHALL still resolve as that hard pin and skip this upgrade. It SHALL NOT apply to a `human_instruction` wake (whose exact target session and live delivery are resolved by the instruction send path, not the wake chokepoint). + +The `Idea` entity carries no pinned-instance columns, so the origin SHALL be taken from the idea's existing session. This direction SHALL apply ONLY when no higher-priority pin matched — that is, only when the connection selection is online-first; a hard mention pin or a soft assignment / idea-instance pin that resolves to an online connection takes priority and SHALL skip this upgrade, preserving the resolution order hard mention pin → soft assignment/idea-instance pin → idea session origin → agent online-first. When the idea has NO existing daemon session (it was elaborated entirely in the UI and the daemon was never woken on it), or that session's origin is offline, the wake SHALL fall back to the existing online-first selection. This wake SHALL reuse the same directed-delivery transport as the pinned `mentioned` / `task_assigned` wakes (the resolved target communicated to the daemon for broadcast suppression); it SHALL NOT introduce an Idea pin column, a new picker, a new permission bit, or a schema migration. + +#### Scenario: A proposal-approval wake targets the idea's session origin + +- **GIVEN** an idea with an existing daemon session whose origin is the ONLINE instance `(Laptop-Q3, dev/ai-pm)` +- **AND** the same agent also has another online instance `(Laptop-Q3, dev/strands)` +- **AND** the idea is NOT pinned to any `agent_instance` +- **WHEN** that idea's proposal is approved and the `proposal_approved` wake is dispatched +- **THEN** only the `dev/ai-pm` daemon MUST wake to handle the approval +- **AND** the `dev/strands` daemon MUST suppress the wake + +#### Scenario: A proposal-rejection wake targets the idea's session origin + +- **GIVEN** an idea with an existing daemon session whose origin is an ONLINE instance, the agent having another online instance, and the idea not pinned to an `agent_instance` +- **WHEN** that idea's proposal is rejected and the `proposal_rejected` wake is dispatched +- **THEN** only the daemon at the idea's session origin MUST wake to handle the rejection +- **AND** the agent's other online instance MUST suppress the wake + +#### Scenario: An idea-claimed wake targets the idea's session origin + +- **GIVEN** an idea with an existing online session origin and an un-pinned assignment, the agent having another online instance +- **WHEN** the `idea_claimed` wake is dispatched +- **THEN** the wake MUST be directed to the idea's session origin rather than agent-overall online-first + +#### Scenario: Verify Elaborate wakes the cwd where the idea conversation already lives + +- **GIVEN** an idea with an existing daemon session whose origin is the ONLINE instance `(Laptop-Q3, dev/ai-pm)` +- **AND** the same agent also has another online instance `(Laptop-Q3, dev/strands)` +- **WHEN** a human clicks "Verify Elaborate" and the `elaboration_verified` wake is dispatched +- **THEN** only the `dev/ai-pm` daemon MUST wake to write the proposal +- **AND** the `dev/strands` daemon MUST suppress the wake + +#### Scenario: An instance-pinned idea takes the pin over the session origin + +- **GIVEN** an idea pinned to the ONLINE `agent_instance` A +- **AND** the idea's existing daemon session origin is a DIFFERENT online instance B of the same agent +- **WHEN** a `proposal_approved` (or any autonomous idea-anchored) wake is dispatched +- **THEN** the wake MUST target instance A (the higher-priority pin) +- **AND** the session-origin upgrade MUST be skipped + +#### Scenario: Falls back to online-first when no session exists + +- **GIVEN** an idea that was elaborated entirely in the UI, with NO existing daemon session +- **WHEN** an autonomous idea-anchored wake (e.g. `proposal_approved` or `elaboration_verified`) is dispatched +- **THEN** the wake MUST fall back to the existing online-first selection (no target is stamped) +- **AND** the behavior MUST match the pre-change wake exactly + +#### Scenario: Falls back when the idea's session origin is offline + +- **GIVEN** an idea whose existing session origin instance is OFFLINE +- **AND** the agent has another online instance +- **WHEN** an autonomous idea-anchored wake is dispatched +- **THEN** it MUST fall back to online-first selection (the offline origin is not wakeable and is not queued) + +#### Scenario: An un-pinned mention to the idea's assignee agent is redirected to the idea session origin + +- **GIVEN** an agent that is the root Idea's assignee, with two online instances, one of which is that idea's ONLINE session origin +- **AND** a `mentioned` notification (on that idea or its comments) that carries no explicit pin +- **WHEN** the wake selects a connection +- **THEN** it MUST be redirected to the idea's session origin connection (a target is stamped and the other instance suppresses the wake) rather than agent-overall online-first + +#### Scenario: An un-pinned mention to a non-assignee agent is not redirected to the idea session origin + +- **GIVEN** a `mentioned` notification, carrying no explicit pin, for an agent that is NOT the root Idea's assignee agent (the idea's session belongs to a different agent) +- **WHEN** the wake selects a connection +- **THEN** the idea-session-origin upgrade MUST NOT apply (that session belongs to another agent) +- **AND** resolution MUST fall through to the project-owner-pin fallback and then to online-first + +#### Scenario: A human instruction is not re-targeted by the wake chokepoint + +- **WHEN** a `human_instruction` wake is processed at the notification chokepoint +- **THEN** the chokepoint MUST NOT apply the idea-session-origin upgrade to it +- **AND** its target and live delivery MUST come solely from the instruction send path diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/specs/project-cwd-anchoring/spec.md b/openspec/changes/mention-wake-respect-pinned-cwd/specs/project-cwd-anchoring/spec.md new file mode 100644 index 00000000..129d768f --- /dev/null +++ b/openspec/changes/mention-wake-respect-pinned-cwd/specs/project-cwd-anchoring/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: Autonomous wakes resolve the agent-owner's fixed project cwd +Autonomous server-minted wakes SHALL resolve a fixed project-Agent cwd before falling back to raw first-online-connection selection, closing the gap where only UI-threaded and stage-advance wakes honored the fixed anchor. The project pin SHALL replace exactly the first-online ("first cwd") fallback and SHALL sit below the existing higher-priority resolution steps: it SHALL apply only when the selection would otherwise be a raw first-online pick — that is, when no idea/task instance pin, no pre-resolved cwd target, and no existing ONLINE idea-session-origin apply. An existing online idea-session-origin (the cwd where the idea's live conversation already runs) SHALL take precedence over the project pin so a live conversation is never rerouted. When those higher-priority steps do not apply, Chorus SHALL look up the `ProjectAgentCwdPreference` of that **Agent's owner** for the wake's `(project, Agent)` pair, and when one exists SHALL treat its `(host, cwd)` as a hard execution anchor and SHALL NOT select the first online connection. When the Agent owner has no preference for that `(project, Agent)`, resolution SHALL fall back to the existing online-first behavior unchanged. This change SHALL add no database schema change, no migration, and no new permission bit. + +For this fallback, "autonomous server-minted wakes" SHALL include the un-pinned `mentioned` wake: the `Agent` is the mentioned agent, and the `project` is the mention target's project (via the mention's root Idea when one exists). Thus an `@mention` of an agent that is pinned only at the project level — with no explicit in-mention pin, no instance pin, and no online idea-session-origin for that agent — SHALL land in the owner-pinned `(host, cwd)` rather than an arbitrary first-online cwd. A `human_instruction` wake SHALL remain excluded from this fallback (its target is resolved by the instruction send path, not this chokepoint). + +#### Scenario: Autonomous wake uses the owner's project pin instead of the first cwd +- **WHEN** an autonomous wake is minted for an Agent that has a fixed project cwd pin set by its owner, the Idea/Task carries no instance pin, and the Agent is online in that pinned cwd plus another cwd +- **THEN** the wake MUST target the owner-pinned `(host, cwd)` +- **AND** it MUST NOT select the other (first-online) cwd + +#### Scenario: An un-pinned mention uses the owner's project pin +- **WHEN** an un-pinned `@mention` wakes an Agent whose owner has a fixed project cwd pin for the mention target's project, the mention carries no explicit pin, no instance pin applies, and the mentioned Agent has no online idea-session-origin for that idea +- **THEN** the wake MUST target the owner-pinned `(host, cwd)` +- **AND** it MUST NOT select an arbitrary first-online cwd + +#### Scenario: Pinned cwd offline does not reroute +- **WHEN** an autonomous wake resolves to the owner's fixed project cwd but that `(host, cwd)` has no online connection, while the same Agent is online in a different cwd +- **THEN** the wake MUST NOT reroute to the other online cwd +- **AND** it MUST follow the existing fixed-anchor hard-pin failure behavior (notify-only for a recoverable wake, reconnect backfill only to the original host and cwd) + +#### Scenario: An online idea-session-origin outranks the project pin +- **WHEN** an autonomous idea-anchored wake has no instance pin, the idea already has an online session-origin in cwd A, and the Agent owner's project pin names a different cwd B +- **THEN** the wake MUST target the existing online session-origin cwd A (the live conversation) +- **AND** it MUST NOT reroute to the project-pinned cwd B + +#### Scenario: No owner preference falls back to online-first +- **WHEN** an autonomous wake is minted for an Agent whose owner has no fixed project cwd preference for that project and Agent +- **THEN** resolution MUST fall back to the existing online-first selection unchanged + +#### Scenario: Pre-resolved and instance-pinned wakes are unaffected +- **WHEN** a wake already carries a pre-resolved cwd target or an idea/task instance pin +- **THEN** its existing resolution precedence MUST be preserved +- **AND** the owner-project-pin step MUST NOT override it diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/tasks.md b/openspec/changes/mention-wake-respect-pinned-cwd/tasks.md new file mode 100644 index 00000000..fee7082e --- /dev/null +++ b/openspec/changes/mention-wake-respect-pinned-cwd/tasks.md @@ -0,0 +1,20 @@ +# Tasks + +## 1. Audit + failing tests for the mention gap +- [ ] Codify a behavior matrix over every `NOTIFICATION_ACTION_TO_TURN_TRIGGER` value × {instance pin, online idea session-origin, project-owner pin, none}, documenting which cwd each resolves to today. +- [ ] Add unit tests encoding the DESIRED post-fix behavior for an un-pinned `mentioned` wake (session-origin upgrade when the mentioned agent owns the root idea's live session; project-owner-pin fallback otherwise) — these fail against current code (red). +- [ ] Add regression tests pinning the unchanged behavior: `task_assigned` ladder, explicit-pin mention, `human_instruction` exclusion, and the no-pin/no-session → online-first path. + +## 2. Implement the fix +- [ ] In `src/services/notification-turn.ts`, remove `mentioned` from the session-origin-upgrade and project-owner-pin exclusion sets; keep `human_instruction` / `resource_resumed` excluded. +- [ ] Resolve the mention's root-Idea anchor (shared root-idea resolver) and apply the session-origin upgrade only when the mentioned agent is that root Idea's assignee agent. +- [ ] Apply the project-owner-pin fallback for the un-pinned mention `(mentioned agent, mention target's project)`. +- [ ] Make task 1's mention tests pass (green); full unit/integration suite green; no schema migration, new permission, or new endpoint. + +## 3. Live e2e with Codex (separate test daemon) +- [ ] Stand up a separate daemon serving Claude + Codex (do NOT restart the daemon serving the working session). +- [ ] Pin Codex at the idea and project level to a chosen cwd. +- [ ] (a) Assign a task to Codex → confirm the wake lands in the pinned cwd. +- [ ] (b) Un-pinned `@mention` of Codex → confirm the wake lands in the pinned cwd (primary fix). +- [ ] (c) Codex `@mention`s the assigner back → confirm the return-wake lands in the assigner's pinned cwd. +- [ ] Capture daemon-log / transcript evidence of the landed cwd per case; hand final sign-off to the human if the live wake cannot be fully closed headlessly. diff --git a/src/services/__tests__/notification-turn.test.ts b/src/services/__tests__/notification-turn.test.ts index e5d4b148..4bd8443b 100644 --- a/src/services/__tests__/notification-turn.test.ts +++ b/src/services/__tests__/notification-turn.test.ts @@ -2127,11 +2127,12 @@ describe("createTurnAndResolveTarget — generalized idea-session-origin upgrade expect(targetConnectionUuid).toBeNull(); }); - // ----- EXCLUSIONS: mentioned + human_instruction are NOT upgraded even with a session ----- + // ----- EXCLUSION: human_instruction is NOT upgraded; mentioned IS now upgraded ----- - it("an un-pinned mentioned wake is NOT redirected to the idea session origin (broadcast, no target)", async () => { - // A resolvable idea session exists, but `mentioned` is excluded from the upgrade set: the - // wake must stay online-first with no target (the un-pinned broadcast contract). + it("an un-pinned mentioned wake IS redirected to the idea session origin (mention-wake-respect-pinned-cwd)", async () => { + // mention-wake-respect-pinned-cwd: `mentioned` now joins the residual upgrade family, so an + // un-pinned mention whose idea has an ONLINE session origin is directed there rather than to + // the raw online-first connection. (A pinned mention still short-circuits as a HARD pin.) const onlineFirst = "conn-online-first"; mockListConnectionsForAgent.mockResolvedValue([ onlineConn({ uuid: onlineFirst, host: "host-A", cwd: "/home/u/dev/a" }), @@ -2144,13 +2145,10 @@ describe("createTurnAndResolveTarget — generalized idea-session-origin upgrade ); expect(mockResolveOrCreateSession).toHaveBeenCalledWith( - expect.objectContaining({ originConnectionUuid: onlineFirst }), - ); - expect(mockResolveOrCreateSession).not.toHaveBeenCalledWith( expect.objectContaining({ originConnectionUuid: ideaOriginConn }), ); - expect(mockDeliverTurnPing).not.toHaveBeenCalled(); - expect(targetConnectionUuid).toBeNull(); + expect(mockDeliverTurnPing).toHaveBeenCalled(); + expect(targetConnectionUuid).toBe(ideaOriginConn); }); it("a human_instruction wake is NOT upgraded to the idea session origin even when one exists", async () => { @@ -2320,3 +2318,133 @@ describe("createTurnAndResolveTarget — project-owner fixed-cwd fallback (idea expect(mockPreferenceFindFirst).not.toHaveBeenCalled(); }); }); + +// ===== Un-pinned @mention residual cwd upgrades (mention-wake-respect-pinned-cwd) ===== +// +// An un-pinned `mentioned` wake for which mention.service resolved NO pin at creation time +// (no explicit (host,cwd) in the markup, not the direct idea's instance assignee, and no +// mentioner-owner project-fixed cwd → nothing threaded onto the context) must now walk the +// SAME residual ladder as `task_assigned`: idea session-origin upgrade → agent-owner project +// pin → online-first. A pinned mention, human_instruction, and the no-session/no-pref case +// are unchanged. These four+ cases are the T1 characterization + the RED target behavior. +describe("createTurnAndResolveTarget — un-pinned @mention residual cwd upgrades", () => { + it("upgrades an un-pinned mention to the root idea's ONLINE session origin (aligned with task_assigned)", async () => { + const originConn = "conn-idea-origin"; + mockListConnectionsForAgent.mockResolvedValue([ + onlineConn({ uuid: "conn-online-first", host: "host-A", cwd: "/a" }), + onlineConn({ uuid: originConn, host: "host-B", cwd: "/idea-home" }), + ]); + // The mention's idea has an existing daemon session whose origin is the 2nd (not-first) conn. + mockDaemonSessionFindFirst.mockResolvedValue({ + uuid: sessionUuid, + originConnectionUuid: originConn, + }); + + const result = await createTurnAndResolveTarget( + ctx({ action: "mentioned", entityType: "idea", entityUuid: ideaUuid }), + ); + + // Directed to the idea's session origin, NOT the raw online-first connection. + expect(result.targetConnectionUuid).toBe(originConn); + expect(mockDeliverTurnPing).toHaveBeenCalled(); + }); + + it("falls back to the mentioned agent's owner project pin when there is no idea session (step 4a)", async () => { + const pinnedConn = "conn-owner-pin"; + mockListConnectionsForAgent.mockResolvedValue([ + onlineConn({ uuid: "conn-online-first", host: "host-A", cwd: "/a" }), + onlineConn({ uuid: pinnedConn, host: "owner-host", cwd: "/owner/cwd" }), + ]); + mockDaemonSessionFindFirst.mockResolvedValue(null); // no idea session → step 4a applies + mockPreferenceFindFirst.mockResolvedValue({ host: "owner-host", cwd: "/owner/cwd" }); + + const result = await createTurnAndResolveTarget( + ctx({ + action: "mentioned", + entityType: "idea", + entityUuid: ideaUuid, + projectUuid: "project-1", + }), + ); + + expect(result.targetConnectionUuid).toBe(pinnedConn); + expect(mockPreferenceFindFirst).toHaveBeenCalled(); + }); + + it("stays online-first for an un-pinned mention with no idea session and no owner project pin (unchanged)", async () => { + const onlineFirst = "conn-online-first"; + mockListConnectionsForAgent.mockResolvedValue([ + onlineConn({ uuid: onlineFirst, host: "host-A", cwd: "/a" }), + ]); + // Defaults: no session (mockDaemonSessionFindFirst → null), no preference. + + const result = await createTurnAndResolveTarget( + ctx({ + action: "mentioned", + entityType: "idea", + entityUuid: ideaUuid, + projectUuid: "project-1", + }), + ); + + expect(result.targetConnectionUuid).toBeNull(); + expect(mockDeliverTurnPing).not.toHaveBeenCalled(); + expect(mockResolveOrCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ originConnectionUuid: onlineFirst }), + ); + }); + + it("an explicit-pin mention still resolves as a HARD pin and SKIPS the residual upgrade", async () => { + mockListConnectionsForAgent.mockResolvedValue([ + onlineConn({ uuid: "conn-pin", host: "pin-host", cwd: "/pin/cwd" }), + onlineConn({ uuid: "conn-idea-origin", host: "host-B", cwd: "/idea-home" }), + ]); + // Even with an online idea session origin present, the explicit pin wins. + mockDaemonSessionFindFirst.mockResolvedValue({ + uuid: sessionUuid, + originConnectionUuid: "conn-idea-origin", + }); + + const result = await createTurnAndResolveTarget( + ctx({ + action: "mentioned", + entityType: "idea", + entityUuid: ideaUuid, + pinnedHost: "pin-host", + pinnedCwd: "/pin/cwd", + }), + ); + + expect(result.targetConnectionUuid).toBe("conn-pin"); + }); + + it("does NOT apply the residual upgrade to a human_instruction wake (still excluded)", async () => { + const onlineFirst = "conn-online-first"; + mockListConnectionsForAgent.mockResolvedValue([ + onlineConn({ uuid: onlineFirst, host: "host-A", cwd: "/a" }), + onlineConn({ uuid: "conn-idea-origin", host: "host-B", cwd: "/idea-home" }), + ]); + // An idea session origin AND an owner project pin both exist, but neither must apply. + mockDaemonSessionFindFirst.mockResolvedValue({ + uuid: sessionUuid, + originConnectionUuid: "conn-idea-origin", + }); + mockPreferenceFindFirst.mockResolvedValue({ host: "host-B", cwd: "/idea-home" }); + + const result = await createTurnAndResolveTarget( + ctx({ + action: "human_instruction", + entityType: "idea", + entityUuid: ideaUuid, + instructionText: "do the thing", + projectUuid: "project-1", + }), + ); + + // human_instruction owns its own target resolution → stays online-first here. + expect(result.targetConnectionUuid).toBeNull(); + expect(mockResolveOrCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ originConnectionUuid: onlineFirst }), + ); + }); +}); diff --git a/src/services/notification-turn.ts b/src/services/notification-turn.ts index f3fc1db4..51ba526d 100644 --- a/src/services/notification-turn.ts +++ b/src/services/notification-turn.ts @@ -166,12 +166,13 @@ const LINEAGE_ENTITY_TYPES = new Set(["task", "document", "proposal", "i * - `elaboration_verified` — the human "Verify Elaborate" → write-the-proposal wake (the * original, now-generalized, home of this upgrade). * - * `mentioned` and `human_instruction` are DELIBERATELY EXCLUDED: an un-pinned `mentioned` - * wake is contractually a broadcast → online-first (stamping no target), a pinned mention - * is resolved as a HARD pin before this branch, and `human_instruction` resolves its own - * exact target + `deliver_turn` ping in `daemon-instruction.service` — upgrading it here - * would double-deliver or mis-route. Both carry their own target resolution, so the - * heuristic session-origin upgrade must never touch them. + * `human_instruction` is DELIBERATELY EXCLUDED from this AUTONOMOUS family: it resolves its + * own exact target + `deliver_turn` ping in `daemon-instruction.service`, so upgrading it here + * would double-deliver or mis-route. `mentioned` is likewise NOT in this autonomous set — but, + * unlike `human_instruction`, an un-pinned `mentioned` wake that resolved NO pin at creation + * time DOES earn the same residual-cwd upgrades via `RESIDUAL_CWD_UPGRADE_TRIGGERS` below + * (mention-wake-respect-pinned-cwd); a pinned mention is still resolved as a HARD pin before + * this branch and short-circuits the upgrade. */ const IDEA_SESSION_ORIGIN_UPGRADE_TRIGGERS = new Set([ "task_assigned", @@ -186,6 +187,32 @@ const IDEA_SESSION_ORIGIN_UPGRADE_TRIGGERS = new Set([ "yolo_requested", ]); +/** + * Triggers eligible for the RESIDUAL-cwd upgrades — the idea session-origin upgrade (step 4) + * and the agent-owner project-cwd fallback (step 4a) — which fire ONLY when connection + * selection would OTHERWISE be a raw online-first pick. This is the AUTONOMOUS idea-anchored + * family (above) PLUS the un-pinned `mentioned` wake (mention-wake-respect-pinned-cwd). + * + * A `mentioned` wake reaches this set already having had its pins resolved at CREATION time by + * `mention.service` (`resolveMentionTarget` → `resolveProjectAgentCwdTarget`): an explicit + * `(host,cwd)` in the markup, the direct idea's `agent_instance` pin, and the MENTIONER-owner's + * project-fixed cwd are all threaded onto the context and become HARD pins in + * `resolvePinnedTarget` — so selection is `directed` / `offline_pin` and this residual step is + * SKIPPED. ONLY when none of those resolved (selection stayed `online_first`) does an un-pinned + * mention walk the SAME residual ladder as `task_assigned`: idea session-origin → the + * MENTIONED-agent-owner project pin → online-first. That makes an un-pinned @mention land where + * the idea's conversation already lives (or the target agent's owner-pinned cwd) instead of a + * random online cwd — and, because an agent→agent return-wake (agent B @mentions the assigner A + * on completion) IS an un-pinned mention, it makes that return-wake land in A's pinned cwd too. + * + * `human_instruction` (owns its own send-path target) and `resource_resumed` (a synthetic + * control dispatch that is never persisted and never reaches this chokepoint) stay excluded. + */ +const RESIDUAL_CWD_UPGRADE_TRIGGERS = new Set([ + ...IDEA_SESSION_ORIGIN_UPGRADE_TRIGGERS, + "mentioned", +]); + /** * Resolve the trigger for a notification action, or null when the action is not * wake-triggering (so the caller skips turn creation entirely). @@ -808,9 +835,11 @@ export async function createTurnAndResolveTarget( // idea's existing ONLINE session origin (where the idea's conversation already lives), // fixing the proposal approve/reject random-cwd wake. No session, an offline origin, or a // non-idea-anchored wake (directIdeaUuid null) → stays online-first. - // `mentioned` / `human_instruction` are excluded from the set (they own their own target). + // Un-pinned `mentioned` is now INCLUDED via RESIDUAL_CWD_UPGRADE_TRIGGERS (a pinned mention + // already resolved a HARD pin above, so it is directed and skips this); `human_instruction` + // is still excluded — it owns its own send-path target. if ( - IDEA_SESSION_ORIGIN_UPGRADE_TRIGGERS.has(trigger) && + RESIDUAL_CWD_UPGRADE_TRIGGERS.has(trigger) && selection.kind === "online_first" ) { const ideaTarget = await resolveIdeaSessionOriginTarget( @@ -837,7 +866,7 @@ export async function createTurnAndResolveTarget( // preference → `makePinnedTarget` null → selection stays `online_first`, unchanged. if ( selection.kind === "online_first" && - IDEA_SESSION_ORIGIN_UPGRADE_TRIGGERS.has(trigger) && + RESIDUAL_CWD_UPGRADE_TRIGGERS.has(trigger) && ctx.projectUuid ) { const projectPin = await resolveProjectOwnerCwdPin( From 6c7472d237dcc21974273c0352ef920ea4f19c1e Mon Sep 17 00:00:00 2001 From: Yifei Chen Date: Fri, 14 Aug 2026 02:32:42 +0000 Subject: [PATCH 2/3] fix(daemon): directed wakes spawn in the resolved connection's cwd, not a stale session cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cross-cwd directed wake (explicit mention pin / instance pin / idea session-origin upgrade) re-points the idea session's originConnectionUuid to the resolved connection but left session.runtimeCwd pointing at the PREVIOUS origin. The server then stamped that stale runtimeCwd onto the deliver_turn ping, and the daemon (selectWaker / Waker.resolveCwd prefer notification.runtimeCwd over the receiving connection's own bound cwd) spawned the agent in the OLD cwd — e.g. an explicit @mention pin to strands still woke codex in ai-pm (idea e40f2b2c live e2e). Stamp the RESOLVED target connection's own cwd (origin.cwd) for a directed wake, and refresh session.runtimeCwd on the cross-cwd re-point, so an explicit pin is fixed to exactly that pin with no fallback. project_fixed / temporary / task-runtime pins keep their explicit pin.runtimeCwd; non-directed wakes are unchanged (broadcast → each connection's own bound cwd). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/notification-turn.test.ts | 57 ++++++++++++++++++- src/services/notification-turn.ts | 19 ++++++- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/services/__tests__/notification-turn.test.ts b/src/services/__tests__/notification-turn.test.ts index 4bd8443b..1ab5f86f 100644 --- a/src/services/__tests__/notification-turn.test.ts +++ b/src/services/__tests__/notification-turn.test.ts @@ -900,12 +900,16 @@ describe("createTurnAndResolveTarget — directed live delivery", () => { expect(mockResolveOrCreateSession).toHaveBeenCalledWith( expect.objectContaining({ originConnectionUuid: pinnedConnUuid }), ); - // deliver_turn ping emitted to ONLY the resolved target, carrying the precise turnUuid. + // deliver_turn ping emitted to ONLY the resolved target, carrying the precise turnUuid + // AND the resolved connection's OWN cwd as runtimeCwd (mention-wake-respect-pinned-cwd: + // an explicit pin is FIXED to that pin's cwd — the daemon must spawn there, not in a + // stale session cwd). expect(mockDeliverTurnPing).toHaveBeenCalledTimes(1); expect(mockDeliverTurnPing).toHaveBeenCalledWith({ companyUuid, originConnectionUuid: pinnedConnUuid, turnUuid: turn?.uuid, + runtimeCwd: pinnedCwd, }); // The resolved target is surfaced transport-only (for non-target broadcast suppression). expect(targetConnectionUuid).toBe(pinnedConnUuid); @@ -914,6 +918,53 @@ describe("createTurnAndResolveTarget — directed live delivery", () => { expect(mockLoggerError).not.toHaveBeenCalled(); }); + // ----- (1b) spawn cwd = the RESOLVED pin, NEVER a stale session cwd (idea e40f2b2c) ----- + + it("a directed (host,cwd) mention whose idea session has a STALE runtimeCwd stamps the PIN's cwd, not the stale one", async () => { + // Daemon-seam repro: the idea's canonical session still lives on an OLD origin (ai-pm) + // carrying a stale runtimeCwd, but THIS @mention is pinned to a DIFFERENT online + // connection (strands). The wake must spawn in the PINNED connection's cwd — falling back + // to the stale session.runtimeCwd is exactly the bug (codex woke in ai-pm despite the + // strands pin, because the daemon honors notification.runtimeCwd over the receiving + // connection's own cwd). So the server MUST stamp the RESOLVED connection's cwd. + const staleCwd = "/home/u/dev/ai-pm"; + const oldOriginUuid = "conn-old-origin-aipm"; + mockListConnectionsForAgent.mockResolvedValue([ + pinnedConn(), // strands-equivalent: host=pinnedHost, cwd=pinnedCwd + onlineConn({ uuid: oldOriginUuid, host: pinnedHost, cwd: staleCwd }), + ]); + // Existing idea session lives on the OLD origin and carries the STALE cwd. + mockDaemonSessionFindFirst.mockResolvedValue({ + uuid: sessionUuid, + originConnectionUuid: oldOriginUuid, + }); + mockResolveOrCreateSession.mockResolvedValue( + sessionView({ originConnectionUuid: pinnedConnUuid, runtimeCwd: staleCwd }), + ); + + const { targetConnectionUuid, runtimeCwd } = await createTurnAndResolveTarget( + ctx({ action: "mentioned", entityType: "idea", entityUuid: ideaUuid, pinnedHost, pinnedCwd }), + ); + + // Directed to the pinned (strands) connection... + expect(targetConnectionUuid).toBe(pinnedConnUuid); + // ...and the spawn cwd is the PIN's cwd, NOT the stale session.runtimeCwd. + expect(runtimeCwd).toBe(pinnedCwd); + expect(mockDeliverTurnPing).toHaveBeenCalledWith( + expect.objectContaining({ originConnectionUuid: pinnedConnUuid, runtimeCwd: pinnedCwd }), + ); + // The cross-cwd re-point ALSO refreshes the stored session.runtimeCwd to the resolved cwd, + // so a later wake never re-reads the stale value. + expect(mockDaemonSessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + originConnectionUuid: pinnedConnUuid, + runtimeCwd: pinnedCwd, + }), + }), + ); + }); + it("a pinned task_assigned wake matching an ONLINE connection: creates the turn, PINGS it, surfaces the target", async () => { pinTaskToInstance(pinnedHost, pinnedCwd); mockListConnectionsForAgent.mockResolvedValue([ @@ -1197,7 +1248,7 @@ describe("createTurnAndResolveTarget — directed live delivery", () => { // The canonical session's origin is RE-POINTED (companyUuid-scoped update on its uuid). expect(mockDaemonSessionUpdate).toHaveBeenCalledWith({ where: { uuid: ideaSessionUuid, companyUuid: companyUuid }, - data: { originConnectionUuid: pinnedConnUuid }, + data: { originConnectionUuid: pinnedConnUuid, runtimeCwd: pinnedCwd }, }); // The turn lands on the SAME canonical session: sessionId === ideaUuid, directIdeaUuid // non-null, origin = the resolved (pinned) connection. NO `::` per-instance thread. @@ -1308,7 +1359,7 @@ describe("createTurnAndResolveTarget — directed live delivery", () => { // Re-point the canonical session — NOT a per-instance fork. expect(mockDaemonSessionUpdate).toHaveBeenCalledWith({ where: { uuid: ideaSessionUuid, companyUuid: companyUuid }, - data: { originConnectionUuid: pinnedConnUuid }, + data: { originConnectionUuid: pinnedConnUuid, runtimeCwd: pinnedCwd }, }); expect(mockResolveOrCreateSession).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/services/notification-turn.ts b/src/services/notification-turn.ts index 51ba526d..2866c78d 100644 --- a/src/services/notification-turn.ts +++ b/src/services/notification-turn.ts @@ -902,7 +902,20 @@ export async function createTurnAndResolveTarget( const origin = selection.connection; const directed = selection.kind === "directed"; + // The wake's spawn cwd. A pin that fixed an explicit runtime cwd (project_fixed / + // temporary / a task instance carrying its own runtimeCwd) keeps it on `pin.runtimeCwd`. + // For a DIRECTED wake resolved to a specific ONLINE connection by `(host, cwd)` — an + // explicit mention pin, an instance pin, or the idea session-origin upgrade — the spawn + // cwd is that RESOLVED connection's OWN cwd (`origin.cwd`), NEVER a stale `session.runtimeCwd` + // left over from a PREVIOUS origin (mention-wake-respect-pinned-cwd, idea e40f2b2c). That + // stale-fallback was the daemon-seam bug: a cross-cwd directed wake re-pointed the session's + // origin but not its runtimeCwd, so the daemon (`selectWaker`/`resolveCwd` prefer + // `notification.runtimeCwd` over the receiving connection's cwd) spawned in the OLD cwd. + // Owner rule: an explicit pin is FIXED to that pin — no fallback. Non-directed (online-first / + // offline / none) never stamps a runtime cwd (broadcast → the daemon uses each connection's + // own bound cwd), so it stays null there. const runtimeCwd = pin?.runtimeCwd ?? null; + const directedRuntimeCwd = directed ? runtimeCwd ?? origin.cwd ?? null : runtimeCwd; // (5) Session business key — ONE conversation per idea per agent (fix idea 2ddd1d11: // "switching daemon cwd / agent splits the chat into two threads → can't interrupt"). @@ -949,7 +962,7 @@ export async function createTurnAndResolveTarget( where: { uuid: existing.uuid, companyUuid: ctx.companyUuid }, data: { originConnectionUuid: origin.uuid, - ...(runtimeCwd ? { runtimeCwd } : {}), + ...(directedRuntimeCwd ? { runtimeCwd: directedRuntimeCwd } : {}), }, }); } @@ -965,9 +978,9 @@ export async function createTurnAndResolveTarget( sessionId, directIdeaUuid: sessionDirectIdeaUuid, originConnectionUuid: origin.uuid, - ...(runtimeCwd ? { runtimeCwd } : {}), + ...(directedRuntimeCwd ? { runtimeCwd: directedRuntimeCwd } : {}), }); - const effectiveRuntimeCwd = runtimeCwd ?? session.runtimeCwd ?? null; + const effectiveRuntimeCwd = directedRuntimeCwd ?? session.runtimeCwd ?? null; const promptText = trigger === "human_instruction" ? ctx.instructionText ?? null : null; From 1f77690854b970aa0cfc3c9246a16dc3cb959777 Mon Sep 17 00:00:00 2001 From: Yifei Chen Date: Fri, 14 Aug 2026 02:45:37 +0000 Subject: [PATCH 3/3] docs(openspec): archive mention-wake-respect-pinned-cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the daemon-cwd-instance-addressing + project-cwd-anchoring deltas to the base specs (un-pinned @mention onto the task_assigned cwd ladder, and a directed wake spawns in the resolved connection's cwd — not a stale session cwd). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../.openspec.yaml | 0 .../README.md | 0 .../design.md | 28 +++++++++++++++ .../proposal.md | 0 .../daemon-cwd-instance-addressing/spec.md | 13 +++++-- .../specs/project-cwd-anchoring/spec.md | 0 .../tasks.md | 24 +++++++++++++ .../mention-wake-respect-pinned-cwd/tasks.md | 20 ----------- .../daemon-cwd-instance-addressing/spec.md | 35 +++++++++++++------ openspec/specs/project-cwd-anchoring/spec.md | 7 ++++ 10 files changed, 95 insertions(+), 32 deletions(-) rename openspec/changes/{mention-wake-respect-pinned-cwd => archive/2026-08-14-mention-wake-respect-pinned-cwd}/.openspec.yaml (100%) rename openspec/changes/{mention-wake-respect-pinned-cwd => archive/2026-08-14-mention-wake-respect-pinned-cwd}/README.md (100%) rename openspec/changes/{mention-wake-respect-pinned-cwd => archive/2026-08-14-mention-wake-respect-pinned-cwd}/design.md (84%) rename openspec/changes/{mention-wake-respect-pinned-cwd => archive/2026-08-14-mention-wake-respect-pinned-cwd}/proposal.md (100%) rename openspec/changes/{mention-wake-respect-pinned-cwd => archive/2026-08-14-mention-wake-respect-pinned-cwd}/specs/daemon-cwd-instance-addressing/spec.md (88%) rename openspec/changes/{mention-wake-respect-pinned-cwd => archive/2026-08-14-mention-wake-respect-pinned-cwd}/specs/project-cwd-anchoring/spec.md (100%) create mode 100644 openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/tasks.md delete mode 100644 openspec/changes/mention-wake-respect-pinned-cwd/tasks.md diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/.openspec.yaml b/openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/.openspec.yaml similarity index 100% rename from openspec/changes/mention-wake-respect-pinned-cwd/.openspec.yaml rename to openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/.openspec.yaml diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/README.md b/openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/README.md similarity index 100% rename from openspec/changes/mention-wake-respect-pinned-cwd/README.md rename to openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/README.md diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/design.md b/openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/design.md similarity index 84% rename from openspec/changes/mention-wake-respect-pinned-cwd/design.md rename to openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/design.md index b35d432e..65430585 100644 --- a/openspec/changes/mention-wake-respect-pinned-cwd/design.md +++ b/openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/design.md @@ -174,3 +174,31 @@ corrections, now reflected in the code comments and the shipped tests: already resolves via the existing step-3a lineage walk. Only the deleted-comment edge (target lookup missing) leaves `entityType: "comment"`, which degrades gracefully to online-first. + +## Post-approval fix 2: directed-wake spawn cwd (the daemon seam) + +The live Codex e2e surfaced a SEPARATE bug that blocked the idea's end-to-end +goal even though the server routing (above) was correct: an explicit `@mention` +pin to `strands` was correctly DIRECTED to Codex's strands connection, yet Codex +physically spawned in `ai-pm` (the daemon's startup cwd). Root cause, confined to +the same `createTurnAndResolveTarget` chokepoint: + +- A `(host, cwd)` pin (explicit mention pin, instance pin, or the session-origin + upgrade) carries no `runtimeCwd`. The cross-cwd re-point updated the session's + `originConnectionUuid` to the resolved connection but left `session.runtimeCwd` + pointing at the PREVIOUS origin. +- The server then stamped that stale `session.runtimeCwd` onto the `deliver_turn` + (`effectiveRuntimeCwd = runtimeCwd ?? session.runtimeCwd`), and the daemon + (`selectWaker` / `Waker.resolveCwd` prefer `notification.runtimeCwd` over the + receiving connection's own bound cwd) spawned the agent in the stale cwd. + +Fix (owner decision: an explicit pin is FIXED to that pin, no fallback): for a +DIRECTED wake, stamp the RESOLVED target connection's own cwd (`origin.cwd`) as +the spawn `runtimeCwd` — `directedRuntimeCwd = pin.runtimeCwd ?? origin.cwd` — +and refresh `session.runtimeCwd` to that value on the cross-cwd re-point. +project-fixed / temporary / task-runtime pins keep their explicit `pin.runtimeCwd` +(may address a cwd no connection is bound to); non-directed (online-first / offline +/ none) wakes are unchanged (broadcast → each connection's own bound cwd). No +schema, endpoint, transport, or daemon-code change; server-only, ECS-deployable. +Verified end-to-end on a fresh daemon (RETEST-A2: explicit pin → strands → +codex `pwd` = strands; return-wake → assigner's ai-pm instance pin). diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/proposal.md b/openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/proposal.md similarity index 100% rename from openspec/changes/mention-wake-respect-pinned-cwd/proposal.md rename to openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/proposal.md diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/specs/daemon-cwd-instance-addressing/spec.md b/openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/specs/daemon-cwd-instance-addressing/spec.md similarity index 88% rename from openspec/changes/mention-wake-respect-pinned-cwd/specs/daemon-cwd-instance-addressing/spec.md rename to openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/specs/daemon-cwd-instance-addressing/spec.md index 666f2798..5624b9dc 100644 --- a/openspec/changes/mention-wake-respect-pinned-cwd/specs/daemon-cwd-instance-addressing/spec.md +++ b/openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/specs/daemon-cwd-instance-addressing/spec.md @@ -4,9 +4,9 @@ When a `task_assigned` or `mentioned` notification wakes an agent, the connection-selection step SHALL honor a pinned target instance if one was recorded with the trigger: it SHALL resolve the `DaemonConnection` matching the pinned `(agentUuid, host, cwd)` AND being ONLINE, and pin the session origin to it. A trigger with NO pin SHALL fall back to the existing online-first connection selection, exactly as before this change. A PINNED trigger whose pin matches NO online connection (the pinned instance is offline, or the place is not registered) SHALL create NO turn and SHALL wake NO instance: the already-recorded notification SHALL stand as the plain record (notify-only) — the wake SHALL NOT silently fall back to a different online instance, because routing a pinned wake to a cwd the user did not choose is the user-visible defect this change fixes. When the agent has NO online connection at all (pinned or not), the wake likewise SHALL create no turn and the notification SHALL stand as the plain record. There is no durable queue or backfill that holds a pinned turn until its instance comes online. The wake SHALL NOT infer a cwd from the project under any circumstance. -Beyond selecting the origin connection, the LIVE wake for a PINNED `task_assigned` or `mentioned` notification SHALL be DIRECTED so that only the daemon at the resolved online `(host, cwd)` instance wakes and answers. The server SHALL emit a `deliver_turn` control ping on the resolved target connection's `control:{connectionUuid}` channel carrying the created turn's precise `turnUuid`, reusing the existing reverse-control / pending-turn machinery that already delivers `human_instruction` turns. Because the notification SSE stream is per-agent (every online connection of the agent receives the same `new_notification`), the resolved target connection SHALL ALSO be communicated to the daemon as transport-only data on the notification the daemon reads (NOT a persisted column) so that each daemon can compare it to its own registered connection identity: a daemon whose own connection identity is NOT the resolved target SHALL suppress the broadcast wake for that pinned notification; the daemon whose connection identity IS the target SHALL wake (and the target's broadcast copy and `deliver_turn` delivery SHALL collapse to exactly one wake via the shared dedup set). A wake that resolves to NO target (a `task_assigned`/`mentioned` wake for which no pin, no online idea session-origin, and no project-owner pin resolved a target, or a pinned/offline wake for which no turn was created) SHALL behave exactly as before this change — no `deliver_turn` is emitted, no suppression occurs, and an un-pinned broadcast wakes the online-first daemon. A daemon that has not yet learned its own connection identity (before the SSE handshake completes) SHALL treat a targeted wake as "not mine" and suppress it, relying on the `deliver_turn` delivery to the actual target and the reconnect pending-turn backfill. The directed-delivery transport SHALL reuse the existing reverse control channel and pending-turn machinery (`control:{connectionUuid}` / `deliver_turn` / the connection-scoped pending-turns read); it SHALL NOT add a new transport, a new permission bit, or a schema migration. +Beyond selecting the origin connection, the LIVE wake for a PINNED `task_assigned` or `mentioned` notification SHALL be DIRECTED so that only the daemon at the resolved online `(host, cwd)` instance wakes and answers. The server SHALL emit a `deliver_turn` control ping on the resolved target connection's `control:{connectionUuid}` channel carrying the created turn's precise `turnUuid`, reusing the existing reverse-control / pending-turn machinery that already delivers `human_instruction` turns. Because the notification SSE stream is per-agent (every online connection of the agent receives the same `new_notification`), the resolved target connection SHALL ALSO be communicated to the daemon as transport-only data on the notification the daemon reads (NOT a persisted column) so that each daemon can compare it to its own registered connection identity: a daemon whose own connection identity is NOT the resolved target SHALL suppress the broadcast wake for that pinned notification; the daemon whose connection identity IS the target SHALL wake (and the target's broadcast copy and `deliver_turn` delivery SHALL collapse to exactly one wake via the shared dedup set). A wake that resolves to NO target (a `task_assigned`/`mentioned` wake for which no pin, no online idea session-origin, and no project-owner pin resolved a target, or a pinned/offline wake for which no turn was created) SHALL behave exactly as before this change — no `deliver_turn` is emitted, no suppression occurs, and an un-pinned broadcast wakes the online-first daemon. A daemon that has not yet learned its own connection identity (before the SSE handshake completes) SHALL treat a targeted wake as "not mine" and suppress it, relying on the `deliver_turn` delivery to the actual target and the reconnect pending-turn backfill. The directed-delivery transport SHALL reuse the existing reverse control channel and pending-turn machinery (`control:{connectionUuid}` / `deliver_turn` / the connection-scoped pending-turns read); it SHALL NOT add a new transport, a new permission bit, or a schema migration. A DIRECTED wake SHALL carry the RESOLVED target connection's OWN cwd as the daemon spawn cwd (the `runtimeCwd` the daemon reads to choose where to start the agent process), NEVER a stale cwd left on the session from a previous origin, so the agent PHYSICALLY spawns in the resolved pinned cwd rather than the daemon's startup cwd or a prior cwd. When the pin itself fixed an explicit runtime cwd (a project-fixed or temporary-runtime target), that explicit runtime cwd SHALL be used; otherwise the resolved online connection's own `(host, cwd)` SHALL be used. This is required because the daemon prefers the wake's `runtimeCwd` over the receiving connection's own bound cwd when selecting the spawn working directory, so a directed wake that omitted or carried a stale `runtimeCwd` would spawn the agent in the wrong cwd even though the wake was correctly routed to the right connection. -For the session business key, a DIRECTED idea-anchored wake SHALL keep ONE conversation per idea per agent (`sessionId === directIdeaUuid`) and SHALL NOT fork a per-instance session. When the resolved online origin connection differs from the idea's existing canonical session origin (the cross-cwd case), the wake SHALL RE-POINT that canonical session's `originConnectionUuid` to the resolved online origin and create the turn on the SAME session row, so the user's turn and the daemon's transcript/turn-lifecycle reports land on the same conversation. This re-point is the second — and only other — deliberate, companyUuid-scoped reversal of the write-once `originConnectionUuid` invariant, alongside the explicit `repointSessionOriginAndSend` send path; the autonomous wake SHALL NOT create a `${directIdeaUuid}::${connectionUuid}` per-instance session. Re-pointing is safe because the daemon probes the on-disk transcript per-cwd and starts a fresh session in a new cwd rather than failing `claude --resume`; prior turns remain as read-only history on the same row. This re-point SHALL NOT add a schema migration, a new column, or a new endpoint. +For the session business key, a DIRECTED idea-anchored wake SHALL keep ONE conversation per idea per agent (`sessionId === directIdeaUuid`) and SHALL NOT fork a per-instance session. When the resolved online origin connection differs from the idea's existing canonical session origin (the cross-cwd case), the wake SHALL RE-POINT that canonical session's `originConnectionUuid` to the resolved online origin and create the turn on the SAME session row, so the user's turn and the daemon's transcript/turn-lifecycle reports land on the same conversation. This re-point is the second — and only other — deliberate, companyUuid-scoped reversal of the write-once `originConnectionUuid` invariant, alongside the explicit `repointSessionOriginAndSend` send path; the autonomous wake SHALL NOT create a `${directIdeaUuid}::${connectionUuid}` per-instance session. Re-pointing is safe because the daemon probes the on-disk transcript per-cwd and starts a fresh session in a new cwd rather than failing `claude --resume`; prior turns remain as read-only history on the same row. The cross-cwd re-point SHALL ALSO refresh the session's stored runtime cwd to the resolved origin connection's cwd, so a later wake that reads the stored value never re-uses the stale cwd of the previous origin. This re-point SHALL NOT add a schema migration, a new column, or a new endpoint. #### Scenario: A pinned online instance is honored at wake time @@ -61,6 +61,15 @@ For the session business key, a DIRECTED idea-anchored wake SHALL keep ONE conve - **AND** it MUST NOT create a `${directIdeaUuid}::${connectionUuid}` per-instance session - **AND** the user's turn and the daemon's later transcript/turn-lifecycle reports MUST land on the same conversation so the running turn is interruptible from the thread the user is viewing +#### Scenario: A directed wake spawns in the resolved connection's cwd, not a stale session cwd + +- **GIVEN** an idea whose existing daemon session origin is `(Laptop-Q3, dev/ai-pm)` with a stored runtime cwd of `dev/ai-pm` +- **AND** a directed wake (an explicit `(host, cwd)` mention pin, an instance pin, or the idea session-origin upgrade) resolves to a DIFFERENT online connection `(Laptop-Q3, dev/strands)` +- **WHEN** the server emits the directed wake +- **THEN** it MUST carry `dev/strands` (the resolved connection's own cwd) as the daemon spawn `runtimeCwd`, NOT the stale `dev/ai-pm` +- **AND** the agent MUST physically spawn in `dev/strands`, not the daemon's startup cwd +- **AND** the cross-cwd re-point MUST refresh the session's stored runtime cwd to `dev/strands` + ### Requirement: The proposal-writing wake SHALL be directed to the idea's existing session origin An autonomous, idea-anchored wake SHALL be directed to the daemon instance where that idea's conversation already lives — its existing `DaemonSession.originConnectionUuid` for the idea-anchored session (`sessionId === directIdeaUuid`) — rather than fanning out to an arbitrary online instance of the agent, whenever the connection selection would otherwise fall to agent-overall online-first. This direction SHALL apply to the autonomous idea-anchored trigger family: the elaboration-resolve / "Verify Elaborate" handoff wake (`elaboration_verified`), the proposal-review wakes (`proposal_approved` and `proposal_rejected`), the idea-claimed wake (`idea_claimed`), the elaboration request/answer wakes (`elaboration_requested` / `elaboration_answered`), and the task-assignment wakes (`task_assigned` / `task_verified` / `task_reopened`) — every wake that resolves to an Idea anchor and is not already pinned. It SHALL ALSO apply to an un-pinned `mentioned` wake, anchored on the mention's **root Idea** (resolved via the shared root-idea resolver), but ONLY when the mentioned agent is that root Idea's assignee agent — i.e. the idea's existing session belongs to the mentioned agent. When the mentioned agent is a different agent, or the mention has no root Idea, this upgrade SHALL NOT apply (the idea's session origin belongs to another agent) and resolution SHALL fall through to the lower-priority steps (project-owner pin, then online-first). A `mentioned` wake that carries an explicit `(host, cwd)` pin SHALL still resolve as that hard pin and skip this upgrade. It SHALL NOT apply to a `human_instruction` wake (whose exact target session and live delivery are resolved by the instruction send path, not the wake chokepoint). diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/specs/project-cwd-anchoring/spec.md b/openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/specs/project-cwd-anchoring/spec.md similarity index 100% rename from openspec/changes/mention-wake-respect-pinned-cwd/specs/project-cwd-anchoring/spec.md rename to openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/specs/project-cwd-anchoring/spec.md diff --git a/openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/tasks.md b/openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/tasks.md new file mode 100644 index 00000000..054c47c9 --- /dev/null +++ b/openspec/changes/archive/2026-08-14-mention-wake-respect-pinned-cwd/tasks.md @@ -0,0 +1,24 @@ +# Tasks + +## 1. Audit + failing tests for the mention gap +- [x] Codify a behavior matrix over every `NOTIFICATION_ACTION_TO_TURN_TRIGGER` value × {instance pin, online idea session-origin, project-owner pin, none}, documenting which cwd each resolves to today. +- [x] Add unit tests encoding the DESIRED post-fix behavior for an un-pinned `mentioned` wake (session-origin upgrade when the mentioned agent owns the root idea's live session; project-owner-pin fallback otherwise) — these fail against current code (red). +- [x] Add regression tests pinning the unchanged behavior: `task_assigned` ladder, explicit-pin mention, `human_instruction` exclusion, and the no-pin/no-session → online-first path. + +## 2. Implement the fix +- [x] In `src/services/notification-turn.ts`, add `RESIDUAL_CWD_UPGRADE_TRIGGERS` (the autonomous idea-anchored family ∪ `mentioned`) and gate the session-origin-upgrade and project-owner-pin steps on it; keep `human_instruction` / `resource_resumed` excluded. +- [x] Audit correction: an un-pinned mention already pre-resolves the direct-idea instance pin and the mentioner-owner project pin at notification-creation time; the genuine gaps closed are the idea session-origin upgrade for `mentioned` and a target-agent-owner project-pin fallback. +- [x] Make task 1's mention tests pass (green); full unit/integration suite green; no schema migration, new permission, or new endpoint. + +## 3. Live e2e with Codex +- [x] Multi-agent daemon already served Claude + Codex (no separate daemon needed); re-tested on a FRESH daemon after each fix. +- [x] (a) Directed pinned wake → confirmed lands in the resolved pinned cwd (RETEST-A2: explicit pin → strands → codex `pwd` = strands). +- [x] (b) Un-pinned `@mention` → server session-origin/project-pin upgrade confirmed directing correctly (broadcast→directed flip); physical landing confirmed by the shared directed-delivery path. +- [x] (c) Codex `@mention`s the assigner back → return-wake landed in the assigner's pinned cwd (Codex → Admin Claude → ai-pm instance pin). +- [x] Captured daemon-log evidence of the landed cwd per case (deliver_turn → resolved connection; codex `pwd` matches). + +## 4. Fix the daemon-seam surfaced by the e2e (directed wake spawn cwd) +- [x] Root-caused: a cross-cwd directed wake re-pointed the session origin but left `session.runtimeCwd` stale, which the server then stamped and the daemon honored over the receiving connection's own cwd — so codex spawned in the old cwd despite correct routing. +- [x] In `src/services/notification-turn.ts`, stamp the RESOLVED target connection's own cwd (`origin.cwd`) for a directed wake instead of the stale `session.runtimeCwd`, and refresh `session.runtimeCwd` on the cross-cwd re-point (owner rule: an explicit pin is fixed to that pin, no fallback). project-fixed / temporary / task-runtime pins keep their explicit `pin.runtimeCwd`; non-directed wakes unchanged. +- [x] Red→green regression test for the stale-session-cwd case; 109 wake-service tests green; `tsc` clean. +- [x] Deployed to live (ECS) and re-verified end-to-end (RETEST-A2). diff --git a/openspec/changes/mention-wake-respect-pinned-cwd/tasks.md b/openspec/changes/mention-wake-respect-pinned-cwd/tasks.md deleted file mode 100644 index fee7082e..00000000 --- a/openspec/changes/mention-wake-respect-pinned-cwd/tasks.md +++ /dev/null @@ -1,20 +0,0 @@ -# Tasks - -## 1. Audit + failing tests for the mention gap -- [ ] Codify a behavior matrix over every `NOTIFICATION_ACTION_TO_TURN_TRIGGER` value × {instance pin, online idea session-origin, project-owner pin, none}, documenting which cwd each resolves to today. -- [ ] Add unit tests encoding the DESIRED post-fix behavior for an un-pinned `mentioned` wake (session-origin upgrade when the mentioned agent owns the root idea's live session; project-owner-pin fallback otherwise) — these fail against current code (red). -- [ ] Add regression tests pinning the unchanged behavior: `task_assigned` ladder, explicit-pin mention, `human_instruction` exclusion, and the no-pin/no-session → online-first path. - -## 2. Implement the fix -- [ ] In `src/services/notification-turn.ts`, remove `mentioned` from the session-origin-upgrade and project-owner-pin exclusion sets; keep `human_instruction` / `resource_resumed` excluded. -- [ ] Resolve the mention's root-Idea anchor (shared root-idea resolver) and apply the session-origin upgrade only when the mentioned agent is that root Idea's assignee agent. -- [ ] Apply the project-owner-pin fallback for the un-pinned mention `(mentioned agent, mention target's project)`. -- [ ] Make task 1's mention tests pass (green); full unit/integration suite green; no schema migration, new permission, or new endpoint. - -## 3. Live e2e with Codex (separate test daemon) -- [ ] Stand up a separate daemon serving Claude + Codex (do NOT restart the daemon serving the working session). -- [ ] Pin Codex at the idea and project level to a chosen cwd. -- [ ] (a) Assign a task to Codex → confirm the wake lands in the pinned cwd. -- [ ] (b) Un-pinned `@mention` of Codex → confirm the wake lands in the pinned cwd (primary fix). -- [ ] (c) Codex `@mention`s the assigner back → confirm the return-wake lands in the assigner's pinned cwd. -- [ ] Capture daemon-log / transcript evidence of the landed cwd per case; hand final sign-off to the human if the live wake cannot be fully closed headlessly. diff --git a/openspec/specs/daemon-cwd-instance-addressing/spec.md b/openspec/specs/daemon-cwd-instance-addressing/spec.md index 918fec18..cce588fa 100644 --- a/openspec/specs/daemon-cwd-instance-addressing/spec.md +++ b/openspec/specs/daemon-cwd-instance-addressing/spec.md @@ -148,9 +148,9 @@ The immediate ad-hoc "send now" flow SHALL let the owner pick which online `(hos When a `task_assigned` or `mentioned` notification wakes an agent, the connection-selection step SHALL honor a pinned target instance if one was recorded with the trigger: it SHALL resolve the `DaemonConnection` matching the pinned `(agentUuid, host, cwd)` AND being ONLINE, and pin the session origin to it. A trigger with NO pin SHALL fall back to the existing online-first connection selection, exactly as before this change. A PINNED trigger whose pin matches NO online connection (the pinned instance is offline, or the place is not registered) SHALL create NO turn and SHALL wake NO instance: the already-recorded notification SHALL stand as the plain record (notify-only) — the wake SHALL NOT silently fall back to a different online instance, because routing a pinned wake to a cwd the user did not choose is the user-visible defect this change fixes. When the agent has NO online connection at all (pinned or not), the wake likewise SHALL create no turn and the notification SHALL stand as the plain record. There is no durable queue or backfill that holds a pinned turn until its instance comes online. The wake SHALL NOT infer a cwd from the project under any circumstance. -Beyond selecting the origin connection, the LIVE wake for a PINNED `task_assigned` or `mentioned` notification SHALL be DIRECTED so that only the daemon at the resolved online `(host, cwd)` instance wakes and answers. The server SHALL emit a `deliver_turn` control ping on the resolved target connection's `control:{connectionUuid}` channel carrying the created turn's precise `turnUuid`, reusing the existing reverse-control / pending-turn machinery that already delivers `human_instruction` turns. Because the notification SSE stream is per-agent (every online connection of the agent receives the same `new_notification`), the resolved target connection SHALL ALSO be communicated to the daemon as transport-only data on the notification the daemon reads (NOT a persisted column) so that each daemon can compare it to its own registered connection identity: a daemon whose own connection identity is NOT the resolved target SHALL suppress the broadcast wake for that pinned notification; the daemon whose connection identity IS the target SHALL wake (and the target's broadcast copy and `deliver_turn` delivery SHALL collapse to exactly one wake via the shared dedup set). A wake that carries NO resolved target (an un-pinned `task_assigned`/`mentioned`, or a pinned/offline wake for which no turn was created) SHALL behave exactly as before this change — no `deliver_turn` is emitted, no suppression occurs, and an un-pinned broadcast wakes the online-first daemon. A daemon that has not yet learned its own connection identity (before the SSE handshake completes) SHALL treat a targeted wake as "not mine" and suppress it, relying on the `deliver_turn` delivery to the actual target and the reconnect pending-turn backfill. The directed-delivery transport SHALL reuse the existing reverse control channel and pending-turn machinery (`control:{connectionUuid}` / `deliver_turn` / the connection-scoped pending-turns read); it SHALL NOT add a new transport, a new permission bit, or a schema migration. +Beyond selecting the origin connection, the LIVE wake for a PINNED `task_assigned` or `mentioned` notification SHALL be DIRECTED so that only the daemon at the resolved online `(host, cwd)` instance wakes and answers. The server SHALL emit a `deliver_turn` control ping on the resolved target connection's `control:{connectionUuid}` channel carrying the created turn's precise `turnUuid`, reusing the existing reverse-control / pending-turn machinery that already delivers `human_instruction` turns. Because the notification SSE stream is per-agent (every online connection of the agent receives the same `new_notification`), the resolved target connection SHALL ALSO be communicated to the daemon as transport-only data on the notification the daemon reads (NOT a persisted column) so that each daemon can compare it to its own registered connection identity: a daemon whose own connection identity is NOT the resolved target SHALL suppress the broadcast wake for that pinned notification; the daemon whose connection identity IS the target SHALL wake (and the target's broadcast copy and `deliver_turn` delivery SHALL collapse to exactly one wake via the shared dedup set). A wake that resolves to NO target (a `task_assigned`/`mentioned` wake for which no pin, no online idea session-origin, and no project-owner pin resolved a target, or a pinned/offline wake for which no turn was created) SHALL behave exactly as before this change — no `deliver_turn` is emitted, no suppression occurs, and an un-pinned broadcast wakes the online-first daemon. A daemon that has not yet learned its own connection identity (before the SSE handshake completes) SHALL treat a targeted wake as "not mine" and suppress it, relying on the `deliver_turn` delivery to the actual target and the reconnect pending-turn backfill. The directed-delivery transport SHALL reuse the existing reverse control channel and pending-turn machinery (`control:{connectionUuid}` / `deliver_turn` / the connection-scoped pending-turns read); it SHALL NOT add a new transport, a new permission bit, or a schema migration. A DIRECTED wake SHALL carry the RESOLVED target connection's OWN cwd as the daemon spawn cwd (the `runtimeCwd` the daemon reads to choose where to start the agent process), NEVER a stale cwd left on the session from a previous origin, so the agent PHYSICALLY spawns in the resolved pinned cwd rather than the daemon's startup cwd or a prior cwd. When the pin itself fixed an explicit runtime cwd (a project-fixed or temporary-runtime target), that explicit runtime cwd SHALL be used; otherwise the resolved online connection's own `(host, cwd)` SHALL be used. This is required because the daemon prefers the wake's `runtimeCwd` over the receiving connection's own bound cwd when selecting the spawn working directory, so a directed wake that omitted or carried a stale `runtimeCwd` would spawn the agent in the wrong cwd even though the wake was correctly routed to the right connection. -For the session business key, a DIRECTED idea-anchored wake SHALL keep ONE conversation per idea per agent (`sessionId === directIdeaUuid`) and SHALL NOT fork a per-instance session. When the resolved online origin connection differs from the idea's existing canonical session origin (the cross-cwd case), the wake SHALL RE-POINT that canonical session's `originConnectionUuid` to the resolved online origin and create the turn on the SAME session row, so the user's turn and the daemon's transcript/turn-lifecycle reports land on the same conversation. This re-point is the second — and only other — deliberate, companyUuid-scoped reversal of the write-once `originConnectionUuid` invariant, alongside the explicit `repointSessionOriginAndSend` send path; the autonomous wake SHALL NOT create a `${directIdeaUuid}::${connectionUuid}` per-instance session. Re-pointing is safe because the daemon probes the on-disk transcript per-cwd and starts a fresh session in a new cwd rather than failing `claude --resume`; prior turns remain as read-only history on the same row. This re-point SHALL NOT add a schema migration, a new column, or a new endpoint. +For the session business key, a DIRECTED idea-anchored wake SHALL keep ONE conversation per idea per agent (`sessionId === directIdeaUuid`) and SHALL NOT fork a per-instance session. When the resolved online origin connection differs from the idea's existing canonical session origin (the cross-cwd case), the wake SHALL RE-POINT that canonical session's `originConnectionUuid` to the resolved online origin and create the turn on the SAME session row, so the user's turn and the daemon's transcript/turn-lifecycle reports land on the same conversation. This re-point is the second — and only other — deliberate, companyUuid-scoped reversal of the write-once `originConnectionUuid` invariant, alongside the explicit `repointSessionOriginAndSend` send path; the autonomous wake SHALL NOT create a `${directIdeaUuid}::${connectionUuid}` per-instance session. Re-pointing is safe because the daemon probes the on-disk transcript per-cwd and starts a fresh session in a new cwd rather than failing `claude --resume`; prior turns remain as read-only history on the same row. The cross-cwd re-point SHALL ALSO refresh the session's stored runtime cwd to the resolved origin connection's cwd, so a later wake that reads the stored value never re-uses the stale cwd of the previous origin. This re-point SHALL NOT add a schema migration, a new column, or a new endpoint. #### Scenario: A pinned online instance is honored at wake time @@ -181,10 +181,10 @@ For the session business key, a DIRECTED idea-anchored wake SHALL keep ONE conve - **THEN** only the `dev/ai-pm` daemon MUST wake and answer - **AND** the `dev/strands` daemon MUST suppress the wake because it is not the resolved target -#### Scenario: An un-pinned mention still wakes the online-first daemon +#### Scenario: An un-pinned mention with no applicable upgrade still wakes the online-first daemon - **GIVEN** an agent with two online instances -- **AND** a `mentioned` notification that carries no pin +- **AND** a `mentioned` notification that carries no pin, whose mentioned agent is NOT the root Idea's assignee (so no idea session-origin applies) and whose owner has no project cwd preference for that project - **WHEN** both daemons receive the broadcast - **THEN** the wake MUST proceed exactly as before this change (no target is stamped, so no suppression occurs and the online-first daemon answers) @@ -205,6 +205,15 @@ For the session business key, a DIRECTED idea-anchored wake SHALL keep ONE conve - **AND** it MUST NOT create a `${directIdeaUuid}::${connectionUuid}` per-instance session - **AND** the user's turn and the daemon's later transcript/turn-lifecycle reports MUST land on the same conversation so the running turn is interruptible from the thread the user is viewing +#### Scenario: A directed wake spawns in the resolved connection's cwd, not a stale session cwd + +- **GIVEN** an idea whose existing daemon session origin is `(Laptop-Q3, dev/ai-pm)` with a stored runtime cwd of `dev/ai-pm` +- **AND** a directed wake (an explicit `(host, cwd)` mention pin, an instance pin, or the idea session-origin upgrade) resolves to a DIFFERENT online connection `(Laptop-Q3, dev/strands)` +- **WHEN** the server emits the directed wake +- **THEN** it MUST carry `dev/strands` (the resolved connection's own cwd) as the daemon spawn `runtimeCwd`, NOT the stale `dev/ai-pm` +- **AND** the agent MUST physically spawn in `dev/strands`, not the daemon's startup cwd +- **AND** the cross-cwd re-point MUST refresh the session's stored runtime cwd to `dev/strands` + ### Requirement: The chat transcript header SHALL surface the session's cwd The daemon conversation transcript header SHALL display the session's working directory (`cwd`) inline as the conversation's instance identity, rendered with the same path-first treatment used elsewhere. The connection's `host` SHALL remain in the existing "Connection details" disclosure rather than the headline, surfaced inline only when needed to disambiguate across hosts. When the session's origin connection reports no cwd, the header SHALL show the "unknown path" treatment consistent with the presence surface. @@ -218,7 +227,7 @@ The daemon conversation transcript header SHALL display the session's working di ### Requirement: The proposal-writing wake SHALL be directed to the idea's existing session origin -An autonomous, idea-anchored wake SHALL be directed to the daemon instance where that idea's conversation already lives — its existing `DaemonSession.originConnectionUuid` for the idea-anchored session (`sessionId === directIdeaUuid`) — rather than fanning out to an arbitrary online instance of the agent, whenever the connection selection would otherwise fall to agent-overall online-first. This direction SHALL apply to the autonomous idea-anchored trigger family: the elaboration-resolve / "Verify Elaborate" handoff wake (`elaboration_verified`), the proposal-review wakes (`proposal_approved` and `proposal_rejected`), the idea-claimed wake (`idea_claimed`), the elaboration request/answer wakes (`elaboration_requested` / `elaboration_answered`), and the task-assignment wakes (`task_assigned` / `task_verified` / `task_reopened`) — every wake that resolves to an Idea anchor and is not already pinned. It SHALL NOT apply to a `mentioned` wake (an un-pinned mention is contractually a broadcast → online-first wake, and a pinned mention is resolved as a hard pin) nor to a `human_instruction` wake (whose exact target session and live delivery are resolved by the instruction send path, not the wake chokepoint). +An autonomous, idea-anchored wake SHALL be directed to the daemon instance where that idea's conversation already lives — its existing `DaemonSession.originConnectionUuid` for the idea-anchored session (`sessionId === directIdeaUuid`) — rather than fanning out to an arbitrary online instance of the agent, whenever the connection selection would otherwise fall to agent-overall online-first. This direction SHALL apply to the autonomous idea-anchored trigger family: the elaboration-resolve / "Verify Elaborate" handoff wake (`elaboration_verified`), the proposal-review wakes (`proposal_approved` and `proposal_rejected`), the idea-claimed wake (`idea_claimed`), the elaboration request/answer wakes (`elaboration_requested` / `elaboration_answered`), and the task-assignment wakes (`task_assigned` / `task_verified` / `task_reopened`) — every wake that resolves to an Idea anchor and is not already pinned. It SHALL ALSO apply to an un-pinned `mentioned` wake, anchored on the mention's **root Idea** (resolved via the shared root-idea resolver), but ONLY when the mentioned agent is that root Idea's assignee agent — i.e. the idea's existing session belongs to the mentioned agent. When the mentioned agent is a different agent, or the mention has no root Idea, this upgrade SHALL NOT apply (the idea's session origin belongs to another agent) and resolution SHALL fall through to the lower-priority steps (project-owner pin, then online-first). A `mentioned` wake that carries an explicit `(host, cwd)` pin SHALL still resolve as that hard pin and skip this upgrade. It SHALL NOT apply to a `human_instruction` wake (whose exact target session and live delivery are resolved by the instruction send path, not the wake chokepoint). The `Idea` entity carries no pinned-instance columns, so the origin SHALL be taken from the idea's existing session. This direction SHALL apply ONLY when no higher-priority pin matched — that is, only when the connection selection is online-first; a hard mention pin or a soft assignment / idea-instance pin that resolves to an online connection takes priority and SHALL skip this upgrade, preserving the resolution order hard mention pin → soft assignment/idea-instance pin → idea session origin → agent online-first. When the idea has NO existing daemon session (it was elaborated entirely in the UI and the daemon was never woken on it), or that session's origin is offline, the wake SHALL fall back to the existing online-first selection. This wake SHALL reuse the same directed-delivery transport as the pinned `mentioned` / `task_assigned` wakes (the resolved target communicated to the daemon for broadcast suppression); it SHALL NOT introduce an Idea pin column, a new picker, a new permission bit, or a schema migration. @@ -274,13 +283,19 @@ The `Idea` entity carries no pinned-instance columns, so the origin SHALL be tak - **WHEN** an autonomous idea-anchored wake is dispatched - **THEN** it MUST fall back to online-first selection (the offline origin is not wakeable and is not queued) -#### Scenario: An un-pinned mention is not redirected to the idea session origin +#### Scenario: An un-pinned mention to the idea's assignee agent is redirected to the idea session origin + +- **GIVEN** an agent that is the root Idea's assignee, with two online instances, one of which is that idea's ONLINE session origin +- **AND** a `mentioned` notification (on that idea or its comments) that carries no explicit pin +- **WHEN** the wake selects a connection +- **THEN** it MUST be redirected to the idea's session origin connection (a target is stamped and the other instance suppresses the wake) rather than agent-overall online-first + +#### Scenario: An un-pinned mention to a non-assignee agent is not redirected to the idea session origin -- **GIVEN** an agent with two online instances, one of which is the origin of some idea's session -- **AND** a `mentioned` notification that carries no pin +- **GIVEN** a `mentioned` notification, carrying no explicit pin, for an agent that is NOT the root Idea's assignee agent (the idea's session belongs to a different agent) - **WHEN** the wake selects a connection -- **THEN** it MUST select the online-first connection with NO target stamped (broadcast), exactly as before this change -- **AND** it MUST NOT be redirected to any idea's session origin +- **THEN** the idea-session-origin upgrade MUST NOT apply (that session belongs to another agent) +- **AND** resolution MUST fall through to the project-owner-pin fallback and then to online-first #### Scenario: A human instruction is not re-targeted by the wake chokepoint diff --git a/openspec/specs/project-cwd-anchoring/spec.md b/openspec/specs/project-cwd-anchoring/spec.md index a298c740..97f33a1d 100644 --- a/openspec/specs/project-cwd-anchoring/spec.md +++ b/openspec/specs/project-cwd-anchoring/spec.md @@ -112,11 +112,18 @@ anchor card while retaining fixed-target execution behavior. ### Requirement: Autonomous wakes resolve the agent-owner's fixed project cwd Autonomous server-minted wakes SHALL resolve a fixed project-Agent cwd before falling back to raw first-online-connection selection, closing the gap where only UI-threaded and stage-advance wakes honored the fixed anchor. The project pin SHALL replace exactly the first-online ("first cwd") fallback and SHALL sit below the existing higher-priority resolution steps: it SHALL apply only when the selection would otherwise be a raw first-online pick — that is, when no idea/task instance pin, no pre-resolved cwd target, and no existing ONLINE idea-session-origin apply. An existing online idea-session-origin (the cwd where the idea's live conversation already runs) SHALL take precedence over the project pin so a live conversation is never rerouted. When those higher-priority steps do not apply, Chorus SHALL look up the `ProjectAgentCwdPreference` of that **Agent's owner** for the wake's `(project, Agent)` pair, and when one exists SHALL treat its `(host, cwd)` as a hard execution anchor and SHALL NOT select the first online connection. When the Agent owner has no preference for that `(project, Agent)`, resolution SHALL fall back to the existing online-first behavior unchanged. This change SHALL add no database schema change, no migration, and no new permission bit. +For this fallback, "autonomous server-minted wakes" SHALL include the un-pinned `mentioned` wake: the `Agent` is the mentioned agent, and the `project` is the mention target's project (via the mention's root Idea when one exists). Thus an `@mention` of an agent that is pinned only at the project level — with no explicit in-mention pin, no instance pin, and no online idea-session-origin for that agent — SHALL land in the owner-pinned `(host, cwd)` rather than an arbitrary first-online cwd. A `human_instruction` wake SHALL remain excluded from this fallback (its target is resolved by the instruction send path, not this chokepoint). + #### Scenario: Autonomous wake uses the owner's project pin instead of the first cwd - **WHEN** an autonomous wake is minted for an Agent that has a fixed project cwd pin set by its owner, the Idea/Task carries no instance pin, and the Agent is online in that pinned cwd plus another cwd - **THEN** the wake MUST target the owner-pinned `(host, cwd)` - **AND** it MUST NOT select the other (first-online) cwd +#### Scenario: An un-pinned mention uses the owner's project pin +- **WHEN** an un-pinned `@mention` wakes an Agent whose owner has a fixed project cwd pin for the mention target's project, the mention carries no explicit pin, no instance pin applies, and the mentioned Agent has no online idea-session-origin for that idea +- **THEN** the wake MUST target the owner-pinned `(host, cwd)` +- **AND** it MUST NOT select an arbitrary first-online cwd + #### Scenario: Pinned cwd offline does not reroute - **WHEN** an autonomous wake resolves to the owner's fixed project cwd but that `(host, cwd)` has no online connection, while the same Agent is online in a different cwd - **THEN** the wake MUST NOT reroute to the other online cwd