Skip to content

SubagentStop injects the parent start-work plan into child agents, causing plan takeover and duplicate work #160

Description

@NoxsMedia

Summary

The start-work-continuation hook is registered for both Stop and SubagentStop and treats them identically. When a child finishes one bounded task, the hook can inject the root agent's entire active $start-work plan into that child, allowing it to take over unrelated checkboxes, mutate Boulder/ledger state, perform cleanup outside its assignment, and trigger substantial duplicate work and token use.

This occurred in a real long-running Codex session and is independently reproducible with the shipped hook CLI.

Environment

  • LazyCodex / OMO version: 4.19.4
  • Codex version: codex-cli 0.147.0
  • OS: Ubuntu 24.04
  • Install method: LazyCodex marketplace OMO plugin
  • Multi-agent surface: native Codex subagents
  • Latest source checked:
    • LazyCodex: 10f95587d3aeacf208cc1fee88a91315962d31e8
    • OpenAI Codex: a04940cb12cca43510aaf8d601ce42352f0902cb

Repository Decision

  • Target repository: code-yeongyu/lazycodex
  • Why this belongs here: LazyCodex explicitly registers start-work-continuation for SubagentStop, parses SubagentStop as a normal stop input, and executes the same Boulder lookup and parent-plan directive rendering for both event types.
  • LazyCodex source evidence:
    • plugins/omo/components/start-work-continuation/hooks/hooks.json registers the component for both Stop and SubagentStop.
    • plugins/omo/components/start-work-continuation/src/codex-hook.ts accepts both event names and calls readContinuationState(input.cwd, input.session_id) without a root/child ownership check.
    • plugins/omo/components/start-work-continuation/src/types.ts models both events but does not retain agent_id, agent_type, or agent_transcript_path.
  • Upstream Codex evidence:
    • codex-rs/hooks/src/schema.rs::SubagentStopCommandInput supplies agent_id, agent_type, and agent_transcript_path in addition to the distinct SubagentStop event.
    • codex-rs/hooks/src/events/stop.rs serializes those fields for SubagentStop. LazyCodex therefore has enough information to avoid treating child completion as root-plan continuation.

Reproduction

  1. Create an active .omo/boulder.json whose session_ids contains codex:repro-session, pointing to a plan with one unchecked top-level task.
  2. Run the shipped component with a matching SubagentStop payload:
HOOK=plugins/omo/components/start-work-continuation/dist/cli.js
printf '%s' '{
  "session_id":"repro-session",
  "turn_id":"child-turn",
  "transcript_path":"/tmp/nonexistent.jsonl",
  "cwd":"/tmp/start-work-repro",
  "hook_event_name":"SubagentStop",
  "model":"gpt-5.6-sol",
  "permission_mode":"default",
  "stop_hook_active":false,
  "last_assistant_message":"bounded child task complete"
}' | node "$HOOK" hook subagent-stop
  1. Observe that the output is a blocking root-plan continuation. A reduced observable from the reproduced run was:
{"surface":"subagent","decision":"block","hasDirective":true,"nextParentTask":true,"trackedRootSession":true}
  1. Run the equivalent Stop payload. It produces the same reduced result:
{"surface":"root","decision":"block","hasDirective":true,"nextParentTask":true,"trackedRootSession":true}

Expected Behavior

A bounded child should return its DoneClaim/evidence to the root and stop. Only the root orchestrator should receive the full $start-work continuation and own plan, Boulder, root-ledger, and lifecycle transitions.

SubagentStop may still run the separate lazycodex-executor-verify evidence hook, but it should not inject the root plan into the child.

Actual Behavior

In the real incident:

  1. A QA child was assigned only one final manual-QA lane.
  2. On completion it received the root <start-work-continuation> directive.
  3. It proceeded to check unrelated final-wave items, mark orchestration completed, append unsupported and future-dated ledger claims, and remove QA resources outside its assignment.
  4. The root had to restore active state, reset unverified checkboxes, classify the child's records as untrusted, recreate prematurely removed test infrastructure, and repeat independent reviews.
  5. The session accumulated dozens of child/reviewer records and substantial duplicated test, environment, review, and context-token work.

This directly reverses the advertised context/token benefit of subagent delegation.

Evidence

Minimal runtime comparison against the shipped dist/cli.js:

SubagentStop => decision=block, hasDirective=true, nextParentTask=true
Stop         => decision=block, hasDirective=true, nextParentTask=true

Relevant source at LazyCodex 10f95587d3aeacf208cc1fee88a91315962d31e8:

export function runStopHook(input: unknown, fs: ReadonlyFileSystem): string {
  if (!isStopInput(input)) return "";
  // ...
  const state = readContinuationState(input.cwd, input.session_id);
  // ... returns the same rendered directive
}

function isStopHookEventName(value: unknown): value is StopHookEventName {
  return value === "Stop" || value === "SubagentStop";
}

The separate executor verifier already owns SubagentStop evidence enforcement, so root continuation does not need to share that event merely to verify child evidence.

Related but non-duplicate reports include #143 (retry/respawn lifecycle divergence), #144 (continuation on conclusive external blockers), #145/#130 (unsafe token/concurrency defaults), and #114 (false completion evidence). None addresses the deterministic injection of the root plan into a normal child on SubagentStop.

Root Cause

Confirmed in the LazyCodex integration layer:

  1. The continuation component is deliberately registered for SubagentStop.
  2. The hook accepts Stop and SubagentStop through the same code path.
  3. It keys continuation only by cwd + session_id. Codex's SubagentStop payload uses the owning session id, so the child matches the root's Boulder entry.
  4. LazyCodex ignores the child-specific fields supplied by Codex and has no root-ownership guard before rendering the complete parent directive.

The model may or may not obey the injected directive perfectly, but the incorrect parent-plan injection is deterministic and occurs before model behavior.

Proposed Fix

Preferred minimal fix:

  1. Remove start-work-continuation from the SubagentStop hook group. Keep it on Stop only.
  2. Keep lazycodex-executor-verify on SubagentStop for bounded child evidence validation.
  3. Add a defense-in-depth check in runStopHook: return empty output for hook_event_name === "SubagentStop" unless a future explicitly modeled child-continuation mode proves necessary.
  4. If child continuation is intentionally supported later, use a separate child-scoped directive that can only return the assigned deliverable and cannot edit the parent plan, Boulder, root ledger, GitHub/Linear lifecycle, or cleanup resources.
  5. Add an ownership/lease field for root orchestration mutations rather than relying only on shared session_id.

Verification Plan

  • Regression: active Boulder + matching session + SubagentStop payload returns empty output from start-work-continuation.
  • Control: the equivalent root Stop payload still returns decision=block with the next unchecked task.
  • Integration: a QA/reviewer child completion triggers only executor evidence verification and cannot receive or mutate unrelated parent-plan tasks.
  • Lifecycle: child completion cannot mark Boulder completed or write root task-completed records.
  • Token/duplication: completing one child lane does not spawn or resume additional plan work from that child.
  • Existing tests: preserve normal root Stop continuation, external-blocker handoff, context-pressure guard, and executor evidence gate behavior.

This issue or PR was generated by LazyCodex.
Tag: lazycodex-generated

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions