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
64 changes: 64 additions & 0 deletions apps/mcp-server/src/tools/ready-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,19 @@ export interface SpecRootSetupIssue extends SpecRootMissingDetails {
message: string;
}

export interface ReadyPlanClaimHint {
kind: 'plan_claim_subtask';
message: string;
plan_slug: string;
subtask_index: number;
title: string;
blocked_by_count: number;
blocked_by: number[];
next_tool: 'task_plan_claim_subtask';
claim_args: TaskPlanClaimArgs;
codex_mcp_call: string;
}

export interface ReadyForAgentResult {
ready: ReadyQueueEntry[];
total_available: number;
Expand All @@ -183,6 +196,7 @@ export interface ReadyForAgentResult {
codex_mcp_call?: string;
next_action_reason?: string;
empty_state?: string;
hint?: ReadyPlanClaimHint;
setup_issue?: SpecRootSetupIssue;
/**
* Populated when the caller passed `auto_claim: true` and the server
Expand Down Expand Up @@ -538,6 +552,7 @@ export async function buildReadyForAgent(
plans.length > 0,
setupIssue,
available.length === 0 && quotaRelays.length === 0 ? staleBlockerRescueCandidate(plans) : null,
planClaimHintForEmptyState(plans, liveSubtaskBranches, args),
);
}

Expand All @@ -555,6 +570,7 @@ function buildReadyResult(
hasPlans: boolean,
setupIssue: SpecRootSetupIssue | null,
rescueCandidate: StaleBlockerRescueCandidate | null,
emptyStateHint: ReadyPlanClaimHint | null,
): ReadyForAgentResult {
if (claimable === null) {
if (base.ready.length > 0) {
Expand Down Expand Up @@ -586,6 +602,7 @@ function buildReadyResult(
...base,
next_action: hasPlans ? NO_READY_SUBTASKS_NEXT_ACTION : NO_PLAN_NEXT_ACTION,
empty_state: NO_CLAIMABLE_PLAN_SUBTASKS_EMPTY_STATE,
...(emptyStateHint ? { hint: emptyStateHint } : {}),
};
}

Expand Down Expand Up @@ -652,6 +669,53 @@ function subtaskBranchKey(repoRoot: string, branch: string): string {
return `${repoRoot}\0${branch}`;
}

function planClaimHintForEmptyState(
plans: PlanInfo[],
liveSubtaskBranches: Set<string>,
args: { session_id: string; agent: string },
): ReadyPlanClaimHint | null {
const candidates = plans
.flatMap((plan) =>
plan.subtasks
.filter((subtask) => subtask.status === 'available')
.filter((subtask) => hasLiveSubtaskBranch(liveSubtaskBranches, plan, subtask))
.map((subtask) => ({ plan, subtask })),
)
.sort(
(left, right) =>
left.subtask.blocked_by_count - right.subtask.blocked_by_count ||
left.plan.created_at - right.plan.created_at ||
left.plan.plan_slug.localeCompare(right.plan.plan_slug) ||
left.subtask.subtask_index - right.subtask.subtask_index,
);
const candidate = candidates[0];
if (!candidate) return null;
const claim_args = {
repo_root: candidate.plan.repo_root,
plan_slug: candidate.plan.plan_slug,
subtask_index: candidate.subtask.subtask_index,
session_id: args.session_id,
agent: args.agent,
file_scope: candidate.subtask.file_scope,
};
const blocked =
candidate.subtask.blocked_by_count > 0
? `; blocked by sub-task(s) ${candidate.subtask.blocked_by.join(', ')}`
: '';
return {
kind: 'plan_claim_subtask',
message: `Unclaimed plan work exists for ${candidate.plan.plan_slug}/sub-${candidate.subtask.subtask_index}${blocked}.`,
plan_slug: candidate.plan.plan_slug,
subtask_index: candidate.subtask.subtask_index,
title: candidate.subtask.title,
blocked_by_count: candidate.subtask.blocked_by_count,
blocked_by: candidate.subtask.blocked_by,
next_tool: 'task_plan_claim_subtask',
claim_args,
codex_mcp_call: codexMcpCall(claim_args),
};
}

function codexMcpCall(args: TaskPlanClaimArgs): string {
return `mcp__colony__task_plan_claim_subtask({ agent: ${JSON.stringify(args.agent)}, session_id: ${JSON.stringify(args.session_id)}, repo_root: ${JSON.stringify(args.repo_root)}, plan_slug: ${JSON.stringify(args.plan_slug)}, subtask_index: ${args.subtask_index}, file_scope: ${JSON.stringify(args.file_scope)} })`;
}
Expand Down
74 changes: 74 additions & 0 deletions apps/mcp-server/test/ready-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,25 @@ interface ReadyResult {
codex_mcp_call?: string;
next_action_reason?: string;
empty_state?: string;
hint?: {
kind: 'plan_claim_subtask';
message: string;
plan_slug: string;
subtask_index: number;
title: string;
blocked_by_count: number;
blocked_by: number[];
next_tool: 'task_plan_claim_subtask';
claim_args: {
repo_root: string;
plan_slug: string;
subtask_index: number;
session_id: string;
agent: string;
file_scope: string[];
};
codex_mcp_call: string;
};
setup_issue?: {
code: 'SPEC_ROOT_NOT_FOUND';
repo_root: string;
Expand Down Expand Up @@ -877,6 +896,61 @@ describe('task_ready_for_agent', () => {
);
});

it('adds a plan-claim hint when unclaimed plan work exists behind blockers', async () => {
await call('task_plan_publish', {
...publishArgs(
[
{
title: 'Blocked API dependency',
description: 'This dependency cannot progress yet.',
file_scope: ['apps/api/blocked-hint-dependency.ts'],
capability_hint: 'api_work',
},
{
title: 'Hidden follow-up API',
description: 'This is unclaimed plan work, but it is not ready yet.',
file_scope: ['apps/api/blocked-hint-followup.ts'],
depends_on: [0],
capability_hint: 'api_work',
},
],
{ slug: 'blocked-hint-plan' },
),
});
const claim = await claimSubtask('blocked-hint-plan', 0);
blockSubtask('blocked-hint-plan', 0, claim.task_id);

const result = await call<ReadyResult>('task_ready_for_agent', {
session_id: 'agent-session',
agent: 'codex',
repo_root: repoRoot,
auto_claim: false,
});

expect(result.ready).toEqual([]);
expect(result.empty_state).toBe(EMPTY_READY_STATE);
expect(result.hint).toMatchObject({
kind: 'plan_claim_subtask',
plan_slug: 'blocked-hint-plan',
subtask_index: 1,
title: 'Hidden follow-up API',
blocked_by_count: 1,
blocked_by: [0],
next_tool: 'task_plan_claim_subtask',
claim_args: {
repo_root: repoRoot,
plan_slug: 'blocked-hint-plan',
subtask_index: 1,
session_id: 'agent-session',
agent: 'codex',
file_scope: ['apps/api/blocked-hint-followup.ts'],
},
});
expect(result.hint?.codex_mcp_call).toBe(
`mcp__colony__task_plan_claim_subtask({ agent: "codex", session_id: "agent-session", repo_root: ${JSON.stringify(repoRoot)}, plan_slug: "blocked-hint-plan", subtask_index: 1, file_scope: ["apps/api/blocked-hint-followup.ts"] })`,
);
});

it('makes a stale blocked wave claimable again after rescue release', async () => {
const t0 = Date.parse('2026-04-28T12:00:00.000Z');
vi.useFakeTimers({ toFake: ['Date'] });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-15
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Why

`task_ready_for_agent` can return an empty ready queue while published plan work still exists behind blockers. The current empty state tells agents there is no claimable work, but it does not preserve the exact follow-up claim call for the next unclaimed sub-task.

## What Changes

- Add an optional `hint` field to empty `task_ready_for_agent` responses when an unclaimed published-plan sub-task exists.
- Include the plan slug, sub-task index, title, blocker metadata, exact `task_plan_claim_subtask` args, and Codex MCP call string.
- Keep existing `ready`, `empty_state`, `next_action`, and claimable-ready behavior unchanged.

## Impact

This is additive response metadata for the MCP ready queue. Existing callers that ignore unknown fields continue to work, while agents that render empty states can now surface plan-claim recovery context.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## ADDED Requirements

### Requirement: Ready Queue Empty-State Plan Hint
When `task_ready_for_agent` returns an empty ready queue but a published plan still contains an unclaimed sub-task, the response SHALL include an optional `hint` object for that sub-task without changing the existing empty-state fields.

#### Scenario: Blocked plan work remains discoverable
- **GIVEN** a published plan has no ready claimable rows because the remaining unclaimed sub-task is blocked by an upstream dependency
- **WHEN** an executor calls `task_ready_for_agent`
- **THEN** the response still has `ready: []` and the existing `empty_state`
- **AND** the response includes `hint.plan_slug`, `hint.subtask_index`, `hint.blocked_by_count`, and `hint.blocked_by`
- **AND** `hint.claim_args` contains the exact `task_plan_claim_subtask` arguments for the unclaimed sub-task
- **AND** `hint.codex_mcp_call` contains the matching Codex MCP call string.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## Definition of Done

This change is complete only when **all** of the following are true:

- Every checkbox below is checked.
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.

## Handoff

- Handoff: change=`agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41`; branch=`agent/codex/plan-claim-nudge-ready-empty-state-2026-05-15-15-41`; scope=`apps/mcp-server/src/tools/ready-queue.ts`, `apps/mcp-server/test/ready-queue.test.ts`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
- Copy prompt: Continue `agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41` on branch `agent/codex/plan-claim-nudge-ready-empty-state-2026-05-15-15-41`. Work inside the existing sandbox, review `openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/plan-claim-nudge-ready-empty-state-2026-05-15-15-41 --base main --via-pr --wait-for-merge --cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41`.
- [x] 1.2 Define normative requirements in `specs/plan-claim-nudge-ready-empty-state/spec.md`.

## 2. Implementation

- [x] 2.1 Implement scoped behavior changes.
- [x] 2.2 Add/update focused regression coverage.

## 3. Verification

- [x] 3.1 Run targeted project verification commands.
- [x] 3.2 Run `openspec validate agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

## 4. Cleanup (mandatory; run before claiming completion)

- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/plan-claim-nudge-ready-empty-state-2026-05-15-15-41 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).
Loading