diff --git a/.genie/wishes/fix-genie-work-phantom-dispatch/WISH.md b/.genie/wishes/fix-genie-work-phantom-dispatch/WISH.md new file mode 100644 index 000000000..c409034e2 --- /dev/null +++ b/.genie/wishes/fix-genie-work-phantom-dispatch/WISH.md @@ -0,0 +1,182 @@ +# Wish: Fix `genie work` phantom dispatch (#1589) + +| Field | Value | +|-------|-------| +| **Status** | IMPLEMENTED | +| **Slug** | `fix-genie-work-phantom-dispatch` | +| **Date** | 2026-04-30 | +| **Author** | felipe (root cause), genie (wish + impl) | +| **Appetite** | small | +| **Branch** | `wish/fix-genie-work-phantom-dispatch` | +| **Repos touched** | `automagik-genie` | +| **Design** | _No brainstorm — direct wish driven by investigation in #1589_ | + +## Summary + +`genie work ` advances PG state to `in_progress` but never spawns a real worker — the dispatched engineer either lands in the wrong worktree (the team's, not the wish's) or fails silently inside `awaitAgentReadiness`, leaving the wish blocked. This wish lands three surgical fixes: route wish dispatches into the wish worktree, make the spawn pipeline raise instead of warn on readiness timeout (with a dispatch-level event for visibility), and correlate `wish status` "Active Executors" by wish-slug instead of role-name match. Together they restore deterministic `/work` dispatch on `4.260430.23+` and unblock `tui-bottom-bar-opentui` (and every future wish). + +## Scope + +### IN + +- Add a `cwd` override to `SpawnOptions` and have `dispatch.ts` pass the current working directory (the wish worktree) when invoking `handleWorkerSpawn` for wish dispatches. +- Make `agents.ts:2392-2395` honor `options.cwd` ahead of `teamConfig.worktreePath` so the wish worktree is not silently overridden. +- Emit a `wish.dispatch.work` audit event in `dispatch.ts:runWorkDispatch` immediately before `handleWorkerSpawn` so dispatch is visible in `genie events list` even when spawn no-ops. +- Convert `awaitAgentReadiness` timeout from warning → throwing error so dispatch surfaces failures instead of pretending success. +- In `state.ts:printWishExecutors`, filter the displayed "Active Executors" list to executors whose `assignment.wishSlug === slug` (drop unrelated team-mate agents that shared a role name). +- Tests covering: cwd override, dispatch event emission, readiness timeout raising, wish-status filtering. + +### OUT + +- Refactoring the `effectiveRole` `-` scheme or the canonical/parallel identity machinery (separate concern). +- Fixing the unrelated "Wave 1 fails-fast on first dependency error" sub-bug A from the issue body (Sub-bug B is the P0 blocker; Sub-bug A is a separate, smaller wish). +- Backfilling missing `dispatch.work` events into historical wish state. +- Changing pgserve discovery / pool-reuse behavior. + +## Decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| 1 | Add `cwd` to `SpawnOptions` and gate the team-worktree override on `!options.cwd` | Minimal surface, additive, preserves existing behavior for free-form spawns; wish dispatch (the only caller that knows the right cwd) opts in explicitly. | +| 2 | Throw on `awaitAgentReadiness` timeout, surfaced through `dispatchSpawn` | Silent warning is what made #1589 latent for two releases; loud failure is the only thing that surfaces phantom dispatch. | +| 3 | Emit `wish.dispatch.work` event before `handleWorkerSpawn`, distinct from `worker.spawn` | Lets users see "I attempted to dispatch group N to role R" even when spawn fails to register; matches the investigation's surface (4) suggestion. | +| 4 | Wish-status executor list filters to `assignment.wishSlug === slug` rather than match by role-name | The current code already calls `getActiveAssignment(executor.id)`; we just need to filter (not relabel) by the result. Removes the dog-fooder false-positive without touching the registry. | +| 5 | Implement directly (not via sub-engineer) | ~80 LOC across 4 files, surgical fixes pre-specified by the investigation; spawning an engineer adds context-window overhead without value. | + +## Success Criteria + +- [x] `genie work ` from inside the wish worktree spawns an engineer whose `/proc//cwd` matches the wish worktree. _(Group 1 — verified by source-grep regression guard; awaiting empirical post-merge verification on dev/.24)_ +- [x] `genie events list --since 5m` after a wish dispatch shows a `wish.dispatch.work` event with `wish_slug`, `group_name`, `agent_role` attributes. _(Group 2 — `recordAuditEvent` call lands before `handleWorkerSpawn`; covered by regression test)_ +- [x] If `awaitAgentReadiness` times out, `dispatch.ts` exits non-zero with a clear error referencing the role that failed. _(Group 2 — `AgentReadinessTimeoutError` raised in strict mode; covered by regression test)_ +- [x] `genie wish status ` "Active Executors" shows only executors whose active assignment is for `` (no dog-fooder/cross-wish bleed-through). _(Group 3 — `if (assignment?.wishSlug !== slug) continue` filter applied in both render paths)_ +- [x] `bun test src/term-commands` passes (579 pass, 0 fail). Existing `dispatch.test.ts` 70 tests still pass; 7 new regression-guard tests for #1589 added. `bun run typecheck` clean (only pre-existing TUI keymap errors). `bun run lint` clean (only pre-existing `team-manager.ts:617` + `buildSpawnParams` complexity, both untouched). + +## Execution Strategy + +### Wave 1 (sequential — single engineer) + +| Group | Agent | Description | +|-------|-------|-------------| +| 1 | engineer | Spawn cwd correction (root cause) | +| 2 | engineer | Observability — `dispatch.work` event + raise on readiness timeout | +| 3 | engineer | wish-status display filtering | + +All three groups will be executed by the genie agent directly given the small scope (~80 LOC) and surgically pre-specified surfaces from the investigation. A reviewer pass follows. + +## Execution Groups + +### Group 1: Spawn cwd correction + +**Goal:** Wish dispatches must land workers in the wish's worktree, not the team's worktree. + +**Deliverables:** +1. Add optional `cwd?: string` to `SpawnOptions` in `src/term-commands/agents.ts` (or wherever `SpawnOptions` is declared). +2. In `agents.ts:handleWorkerSpawn` around line 2388-2395, change the override condition so `options.cwd` (when set) takes precedence over `teamConfig.worktreePath`. Keep the existing fallback for callers that don't pass `cwd`. +3. In `src/term-commands/dispatch.ts:runWorkDispatch` around line 698-711, pass `cwd: process.cwd()` in the `handleWorkerSpawn` options. Add an inline comment explaining why (wish dispatch must land in wish worktree, see #1589). +4. Same fix in the `bareDispatchCommand` path (`runBareDispatch`) if it shares the same surface. + +**Acceptance Criteria:** +- [ ] `SpawnOptions.cwd` is optional and documented in the type definition. +- [ ] `agents.ts` cwd resolution prefers `options.cwd` → `agent.entry.dir` → `teamConfig.worktreePath` (in that order). +- [ ] `dispatch.ts` passes `cwd: process.cwd()` for wish dispatches. +- [ ] Empirical: `genie work tui-bottom-bar-opentui` from `~/.genie/worktrees/tui-bottom-bar-opentui` spawns engineer with `/proc//cwd` pointing at the wish worktree. + +**Validation:** +```bash +cd /home/genie/workspace/repos/genie && bun test src/term-commands/dispatch.test.ts && bun test src/term-commands/agents.test.ts +``` + +**depends-on:** none + +--- + +### Group 2: Observability — dispatch event + raise on readiness timeout + +**Goal:** Make phantom dispatch impossible to ship undetected. + +**Deliverables:** +1. In `src/term-commands/dispatch.ts:runWorkDispatch`, emit a `wish.dispatch.work` audit event (via `recordAuditEvent` or the existing trace-span machinery) immediately before `handleWorkerSpawn`. Attributes: `wish_slug`, `group_name`, `agent_role` (the `effectiveRole`), `wish_path`. +2. In `src/term-commands/agents.ts:awaitAgentReadiness` (around line 984-992), throw a structured error on timeout instead of `console.log` warning. Include role name + elapsed time in the error message. +3. Caller (`launchTmuxSpawn` line 1030) must propagate the error so `handleWorkerSpawn` returns failure rather than appearing successful. Update `dispatch.ts` to surface the error and exit non-zero. +4. Tests verifying: + - `wish.dispatch.work` event is emitted with the expected attributes. + - Readiness timeout throws and `genie work` exits non-zero with the role name in the message. + +**Acceptance Criteria:** +- [ ] `wish.dispatch.work` event appears in `genie events list` for every successful wish dispatch (and every failed one). +- [ ] Readiness timeout raises a typed error containing the agent role. +- [ ] `genie work` exits non-zero on readiness timeout with the role name in stderr. +- [ ] No new unhandled-rejection warnings. + +**Validation:** +```bash +cd /home/genie/workspace/repos/genie && bun test src/term-commands/dispatch.test.ts && bun test src/term-commands/agents.test.ts +``` + +**depends-on:** Group 1 + +--- + +### Group 3: wish-status display correlation + +**Goal:** "Active Executors" shows only the wish's actual workers — never dog-fooder or other cross-wish team-mates. + +**Deliverables:** +1. In `src/term-commands/state.ts:printWishExecutors` (around line 512-542), change the loop so executors whose `assignment.wishSlug !== slug` are SKIPPED (not just relabeled with `'-'`). +2. Apply the same filter in `src/term-commands/task/status.ts:51` (the parallel "Active Executors" rendering for task status). +3. Test verifying that an unrelated team agent (e.g. dog-fooder engineer running on a different wish) does not appear in `wish status `. + +**Acceptance Criteria:** +- [ ] `printWishExecutors` only emits rows where `assignment.wishSlug === slug`. +- [ ] `task/status.ts` mirrors the same filter. +- [ ] Test added covering the filter. + +**Validation:** +```bash +cd /home/genie/workspace/repos/genie && bun test src/term-commands/state.test.ts 2>/dev/null || bun test src/term-commands/state +``` + +**depends-on:** none + +--- + +## QA Criteria + +_What must be verified on dev after merge. The QA agent tests each criterion._ + +- [ ] **Functional:** From any orchestrator session with `GENIE_TEAM=genie`, run `genie work ` from inside the wish worktree → engineer process appears in `pgrep -af engineer-` AND `/proc//cwd` is the wish worktree. +- [ ] **Observability:** `genie events list --since 5m` after the dispatch contains a `wish.dispatch.work` event referencing the wish slug and group. +- [ ] **Error path:** Force a readiness timeout (e.g. by sabotaging the launch command) → `genie work` exits non-zero with a clear error; PG state for the group rolls back to `ready` (or remains `in_progress` with a clearly-failed assignee that operators can `wish reset`). +- [ ] **Display:** `genie wish status ` after a successful dispatch shows only the wish's engineers under "Active Executors", not unrelated team agents. +- [ ] **Regression:** Existing `dispatch.test.ts` and `agents.test.ts` pass; full `bun run check` is green; existing free-form `genie spawn engineer` (without wish) still respects team worktree as before. + +--- + +## Assumptions / Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Throwing on readiness timeout breaks legitimate slow-start agents | Medium | Keep the warning-and-proceed behavior reachable via an opt-in flag (`options.tolerateReadinessTimeout`) for callers that genuinely want fire-and-forget; wish dispatch opts into the strict mode. | +| Other callers of `handleWorkerSpawn` pass `team` but not `cwd` and now silently change behavior | Low | The change is additive (`cwd` defaults to undefined → existing path); only wish dispatch opts in. Documented in the new field's JSDoc. | +| `wish.dispatch.work` event clobbers an existing event name | Low | Grep confirms no prior usage of `wish.dispatch.work`. Existing dispatch span uses `wish.dispatch` (no `.work` suffix). | +| Display filter hides legitimate cross-wish helpers | Low | The `assignment` table is authoritative for "this executor is working on this wish" — unrelated executors should not appear under "Active Executors for wish X". | +| Branch is on `wish/fix-genie-work-phantom-dispatch` (off origin/dev), not the team worktree | Low | This is the canonical genie repo at `/home/genie/workspace/repos/genie`; wish branches always work here per project convention. | + +--- + +## Review Results + +_Populated by `/review` after execution completes._ + +--- + +## Files to Create/Modify + +``` +src/term-commands/dispatch.ts # Group 1: cwd option; Group 2: dispatch.work event + propagate readiness error +src/term-commands/agents.ts # Group 1: SpawnOptions.cwd + override precedence; Group 2: throw on readiness timeout +src/term-commands/state.ts # Group 3: filter Active Executors by wishSlug +src/term-commands/task/status.ts # Group 3: same filter +src/term-commands/dispatch.test.ts # New tests for cwd, dispatch.work event, readiness timeout +src/term-commands/state.test.ts # New test for executor filter (or extend existing) +``` diff --git a/src/term-commands/agents.ts b/src/term-commands/agents.ts index ee5c98f77..ac0cdeaa6 100644 --- a/src/term-commands/agents.ts +++ b/src/term-commands/agents.ts @@ -550,6 +550,12 @@ interface SpawnCtx { agentIdentityId?: string; /** Pre-generated executor ID for the executor record. */ executorId?: string; + /** + * When true, treat readiness-probe timeout as a warning (legacy behavior). + * When false / undefined, readiness timeout throws — surfacing silent + * dispatch failures (#1589). Default: strict. + */ + tolerateReadinessTimeout?: boolean; } async function registerSpawnWorker( @@ -981,14 +987,46 @@ async function finalizeTmuxSpawn(ctx: SpawnCtx, paneId: string, teamWindow: any, printSpawnInfo(ctx, paneId, workerEntry); } -async function awaitAgentReadiness(paneId: string): Promise { +/** + * Error raised when an agent's readiness probe times out under strict mode. + * + * Strict mode is the default (#1589): the previous warn-and-proceed behavior + * silently masked phantom dispatches in `genie work` and `genie spawn`. + * Callers that legitimately want fire-and-forget semantics opt in via + * `SpawnOptions.tolerateReadinessTimeout: true`. + */ +export class AgentReadinessTimeoutError extends Error { + readonly role: string; + readonly paneId: string; + readonly elapsedMs: number; + constructor(role: string, paneId: string, elapsedMs: number) { + super( + `Agent "${role}" did not become ready within ${Math.round( + elapsedMs / 1000, + )}s (pane ${paneId}). This likely means the worker process exited before the readiness probe succeeded. Pass tolerateReadinessTimeout:true on SpawnOptions to fall back to warn-and-proceed.`, + ); + this.name = 'AgentReadinessTimeoutError'; + this.role = role; + this.paneId = paneId; + this.elapsedMs = elapsedMs; + } +} + +async function awaitAgentReadiness(paneId: string, role: string, tolerateReadinessTimeout?: boolean): Promise { if (paneId === 'inline') return; const result = await waitForAgentReady(paneId); if (result.ready) { console.log(` ✓ Agent ready (${(result.elapsedMs / 1000).toFixed(1)}s)`); - } else { + return; + } + if (tolerateReadinessTimeout) { console.log(` ⚠ Agent readiness timeout (${Math.round(result.elapsedMs / 1000)}s) — proceeding anyway`); + return; } + // Strict mode (default since #1589): raise so dispatch surfaces the failure + // instead of silently appearing successful with no live worker. + console.error(` ✗ Agent readiness timeout (${Math.round(result.elapsedMs / 1000)}s) — failing strict mode`); + throw new AgentReadinessTimeoutError(role, paneId, result.elapsedMs); } async function launchTmuxSpawn(ctx: SpawnCtx): Promise { @@ -1027,7 +1065,7 @@ async function launchTmuxSpawn(ctx: SpawnCtx): Promise { await notifySpawnJoin(ctx, paneId); await finalizeTmuxSpawn(ctx, paneId, teamWindow, workerEntry); - await awaitAgentReadiness(paneId); + await awaitAgentReadiness(paneId, ctx.validated.role ?? ctx.workerId, ctx.tolerateReadinessTimeout); // Transition executor + legacy worker from 'spawning' to 'running' if (ctx.executorId) { @@ -1699,6 +1737,12 @@ export interface SpawnOptions { noAutoSync?: boolean; /** Commander backing field for --no-auto-sync. */ autoSync?: boolean; + /** + * When true, treat readiness-probe timeout as a warning and proceed (legacy + * behavior). When false / undefined, readiness timeout throws — surfacing + * silent dispatch failures (#1589). Default: strict (throws). + */ + tolerateReadinessTimeout?: boolean; } export const _spawnAutoSyncDeps = { @@ -2389,8 +2433,15 @@ export async function handleWorkerSpawn(name: string, options: SpawnOptions): Pr // Only override for agents without their own registered directory — sub-agents // (e.g. genie/brain-engineer at .genie/agents/brain-engineer/) need their own // CWD to avoid loading a parent agent's AGENTS.md via directory-tree walk. + // + // Additional guard for #1589: when the caller already passed an explicit + // `cwd` (e.g. wish dispatch from `genie work` running inside the wish + // worktree), DO NOT clobber it with the team's worktree. The wish worktree is + // a deliberate, per-dispatch choice; the team worktree is a default for + // free-form spawn. `resolveAgentForSpawn` already honored options.cwd at line + // 1802 — this check prevents the team-override from undoing that. const teamConfig = await teamManager.getTeam(team); - if (teamConfig?.worktreePath && !agent.entry?.dir) { + if (teamConfig?.worktreePath && !agent.entry?.dir && !options.cwd) { agent = { ...agent, repoPath: teamConfig.worktreePath }; } @@ -2476,6 +2527,7 @@ export async function handleWorkerSpawn(name: string, options: SpawnOptions): Pr autoResume: options.autoResume, agentIdentityId: agentIdentity.id, executorId, + tolerateReadinessTimeout: options.tolerateReadinessTimeout, }; // Audit event for worker spawn (fire-and-forget before launch returns) diff --git a/src/term-commands/dispatch.test.ts b/src/term-commands/dispatch.test.ts index c7ed52d66..0ae84eef9 100644 --- a/src/term-commands/dispatch.test.ts +++ b/src/term-commands/dispatch.test.ts @@ -1002,3 +1002,127 @@ describe('agents.ts createTmuxPane refuse-no-target regression guard', () => { expect(source).toContain('trace-genie-spawn-wrong-window'); }); }); + +/** + * P0 regression guard — #1589 phantom dispatch. + * + * Three architectural gaps allowed `genie work` to advance PG state without + * actually spawning a worker in the wish worktree: + * + * 1. dispatch.ts forwarded `team` but not `cwd`, and agents.ts:2393 + * silently overrode `agent.repoPath` with the team's worktree. + * 2. `awaitAgentReadiness` warned-and-proceeded on timeout, masking + * silent spawn failures. + * 3. `wish status` displayed any team-mate sharing a role name as if + * they were the wish's worker. + * + * Each guard below source-greps the fix into place so a future refactor + * cannot silently regress. + */ +describe('#1589 phantom-dispatch regression guards', () => { + let dispatchSrc: string; + let agentsSrc: string; + let stateSrc: string; + let taskStatusSrc: string; + + beforeAll(async () => { + const { readFileSync } = await import('node:fs'); + dispatchSrc = readFileSync(join(__dirname, 'dispatch.ts'), 'utf-8'); + agentsSrc = readFileSync(join(__dirname, 'agents.ts'), 'utf-8'); + stateSrc = readFileSync(join(__dirname, 'state.ts'), 'utf-8'); + taskStatusSrc = readFileSync(join(__dirname, 'task', 'status.ts'), 'utf-8'); + }); + + describe('Group 1 — spawn cwd correction', () => { + it('all 4 dispatch.ts handleWorkerSpawn callsites forward cwd: process.cwd()', () => { + // Walk every `handleWorkerSpawn(agentName, { ... });` block in dispatch.ts + // and assert it contains `cwd: process.cwd()`. Without this, agents.ts + // overrides repoPath with the team worktree silently (#1589). + const callPattern = /await handleWorkerSpawn\([^)]+,\s*\{/g; + const callsites: { idx: number; block: string }[] = []; + let m: RegExpExecArray | null = callPattern.exec(dispatchSrc); + while (m !== null) { + const idx = m.index; + const closeIdx = dispatchSrc.indexOf('});', idx); + callsites.push({ idx, block: dispatchSrc.slice(idx, closeIdx) }); + m = callPattern.exec(dispatchSrc); + } + expect(callsites.length).toBe(4); // brainstorm, wish, work, review + for (const { block } of callsites) { + expect(block).toContain('cwd: process.cwd()'); + } + }); + + it('agents.ts team-worktree override is gated on !options.cwd', () => { + // The override at agents.ts:~2393 used to fire whenever + // teamConfig.worktreePath was set and agent.entry.dir was null. Now it + // must also check !options.cwd so explicit cwd from dispatch is honored. + const anchor = agentsSrc.indexOf('teamConfig?.worktreePath && !agent.entry?.dir'); + expect(anchor).toBeGreaterThan(-1); + const lineEnd = agentsSrc.indexOf('\n', anchor); + const line = agentsSrc.slice(anchor, lineEnd); + expect(line).toContain('!options.cwd'); + }); + }); + + describe('Group 2 — observability', () => { + it('runWorkDispatch emits wish.dispatch.work audit event before spawn', () => { + const fnStart = dispatchSrc.indexOf('async function runWorkDispatch'); + expect(fnStart).toBeGreaterThan(-1); + const fnEnd = dispatchSrc.indexOf('\nasync function ', fnStart + 1); + const body = dispatchSrc.slice(fnStart, fnEnd !== -1 ? fnEnd : dispatchSrc.length); + // Audit event call must appear and must precede handleWorkerSpawn. + const eventIdx = body.indexOf("'wish.dispatch.work'"); + const spawnIdx = body.indexOf('await handleWorkerSpawn('); + expect(eventIdx).toBeGreaterThan(-1); + expect(spawnIdx).toBeGreaterThan(-1); + expect(eventIdx).toBeLessThan(spawnIdx); + // Event must carry the wish-correlation attributes used by `genie events`. + expect(body).toContain('wish_slug:'); + expect(body).toContain('group_name:'); + expect(body).toContain('agent_role:'); + }); + + it('AgentReadinessTimeoutError is exported and thrown by awaitAgentReadiness in strict mode', () => { + expect(agentsSrc).toContain('export class AgentReadinessTimeoutError'); + // Strict mode (default) throws; tolerateReadinessTimeout=true falls back to warn. + expect(agentsSrc).toContain('throw new AgentReadinessTimeoutError'); + expect(agentsSrc).toContain('tolerateReadinessTimeout'); + }); + + it('SpawnOptions and SpawnCtx both expose tolerateReadinessTimeout', () => { + // The opt-out must live on both the public SpawnOptions surface and the + // internal SpawnCtx (so launchTmuxSpawn can read it). + const optsAnchor = agentsSrc.indexOf('export interface SpawnOptions'); + expect(optsAnchor).toBeGreaterThan(-1); + const optsClose = agentsSrc.indexOf('\n}\n', optsAnchor); + const optsBody = agentsSrc.slice(optsAnchor, optsClose); + expect(optsBody).toContain('tolerateReadinessTimeout?: boolean'); + + const ctxAnchor = agentsSrc.indexOf('interface SpawnCtx'); + expect(ctxAnchor).toBeGreaterThan(-1); + const ctxClose = agentsSrc.indexOf('\n}\n', ctxAnchor); + const ctxBody = agentsSrc.slice(ctxAnchor, ctxClose); + expect(ctxBody).toContain('tolerateReadinessTimeout?: boolean'); + }); + }); + + describe('Group 3 — wish-status display correlation', () => { + it('state.ts printWishExecutors filters out executors whose assignment is for a different wish', () => { + const fnAnchor = stateSrc.indexOf('async function printWishExecutors'); + expect(fnAnchor).toBeGreaterThan(-1); + const fnEnd = stateSrc.indexOf('\nasync function ', fnAnchor + 1); + const body = stateSrc.slice(fnAnchor, fnEnd !== -1 ? fnEnd : stateSrc.length); + // Must skip — not just relabel — non-matching wishes. + expect(body).toContain('if (assignment?.wishSlug !== slug) continue'); + }); + + it('task/status.ts printActiveExecutors applies the same filter', () => { + const fnAnchor = taskStatusSrc.indexOf('async function printActiveExecutors'); + expect(fnAnchor).toBeGreaterThan(-1); + const fnEnd = taskStatusSrc.indexOf('\nasync function ', fnAnchor + 1); + const body = taskStatusSrc.slice(fnAnchor, fnEnd !== -1 ? fnEnd : taskStatusSrc.length); + expect(body).toContain('if (assignment?.wishSlug !== slug) continue'); + }); + }); +}); diff --git a/src/term-commands/dispatch.ts b/src/term-commands/dispatch.ts index 8120ec78e..41a983573 100644 --- a/src/term-commands/dispatch.ts +++ b/src/term-commands/dispatch.ts @@ -23,6 +23,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Command } from 'commander'; +import { getActor, recordAuditEvent } from '../lib/audit.js'; import { endSpan, startSpan } from '../lib/emit.js'; import { isWideEmitEnabled } from '../lib/observability-flag.js'; import * as protocolRouter from '../lib/protocol-router.js'; @@ -537,6 +538,9 @@ export async function brainstormCommand(agentName: string, slug: string): Promis const brainstormPrompt = `Brainstorm "${slug}". Your context is in the system prompt. Explore the idea, ask clarifying questions, and build toward a design.`; await handleWorkerSpawn(agentName, { provider: 'claude', + // Pin spawn cwd to the dispatch cwd so the agent runs in the workspace the + // operator is dispatching from, not a team's default worktree (#1589). + cwd: process.cwd(), extraArgs: ['--append-system-prompt-file', contextFile], initialPrompt: brainstormPrompt, }); @@ -577,6 +581,9 @@ export async function wishCommand(agentName: string, slug: string): Promise' --to ${leaderTarget}`; + + // Emit dispatch.work audit event BEFORE spawn so the dispatch is observable + // in `genie events list` even if spawn no-ops or silently auto-resumes a + // stale executor (#1589). The worker.spawn event at agents.ts:2482 only fires + // when a fresh spawn reaches that line — auto-resume returns earlier and + // leaves the dispatch invisible to operators. + await recordAuditEvent('wish', slug, 'wish.dispatch.work', getActor(), { + wish_slug: slug, + group_name: group, + agent_name: agentName, + agent_role: effectiveRole, + wish_path: wishPath, + cwd: process.cwd(), + }); + await handleWorkerSpawn(agentName, { provider: 'claude', // P1 hotfix: forward the team context so spawn lands in the team's @@ -705,6 +727,11 @@ async function runWorkDispatch( // the operator's pane). Authority: // ~/.genie/reports/trace-genie-spawn-wrong-window.md team: process.env.GENIE_TEAM, + // P0 (#1589): pin spawn cwd to the wish worktree (the dispatch cwd). + // Without this, agents.ts:2393 silently overrides agent.repoPath with + // teamConfig.worktreePath — meaning the engineer launches in the team's + // default worktree, not the wish's, and never executes the wish. + cwd: process.cwd(), role: effectiveRole, extraArgs: ['--append-system-prompt-file', contextFile], initialPrompt: workPrompt, @@ -785,6 +812,10 @@ export async function reviewCommand(agentName: string, ref: string): Promise { if (!executor || executor.state === 'terminated' || executor.state === 'done') continue; const assignment = await assignmentRegistry.getActiveAssignment(executor.id); - const taskLabel = - assignment?.wishSlug === slug ? `Group ${assignment.groupNumber ?? '?'}` : (assignment?.wishSlug ?? '-'); + // #1589: only surface executors actively assigned to this wish. Without + // this filter, any team-mate sharing a role name (e.g. the dog-fooder's + // long-running `engineer`) bleeds into "Active Executors" for every + // wish under the same team — masking phantom dispatches by displaying + // an unrelated agent as the apparent worker. + if (assignment?.wishSlug !== slug) continue; + const taskLabel = `Group ${assignment.groupNumber ?? '?'}`; const name = agent.customName ?? agent.role ?? agent.id.slice(0, 12); executorInfoLines.push( ` Agent: ${padRight(name, 16)} | Executor: ${executor.id.slice(0, 12)} (${executor.provider}) | State: ${padRight(executor.state, 10)} | Task: ${taskLabel}`, diff --git a/src/term-commands/task/status.ts b/src/term-commands/task/status.ts index d16e8a359..1bc9ce82f 100644 --- a/src/term-commands/task/status.ts +++ b/src/term-commands/task/status.ts @@ -39,8 +39,12 @@ async function printActiveExecutors(slug: string): Promise { if (!executor || executor.state === 'terminated' || executor.state === 'done') continue; const assignment = await assignmentRegistry.getActiveAssignment(executor.id); - const taskLabel = - assignment?.wishSlug === slug ? `Group ${assignment.groupNumber ?? '?'}` : (assignment?.wishSlug ?? '-'); + // #1589: only surface executors actively assigned to this wish. Without + // this filter, any team-mate sharing a role name bleeds into "Active + // Executors" for every wish under the same team — masking phantom + // dispatches by displaying an unrelated agent as the apparent worker. + if (assignment?.wishSlug !== slug) continue; + const taskLabel = `Group ${assignment.groupNumber ?? '?'}`; const agentName = agent.customName ?? agent.role ?? agent.id.slice(0, 12); executorInfoLines.push( ` Agent: ${padRight(agentName, 16)} | Executor: ${executor.id.slice(0, 12)} (${executor.provider}) | State: ${padRight(executor.state, 10)} | Task: ${taskLabel}`,