Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-13
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# 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.

## 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).
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading