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: 56 additions & 8 deletions apps/mcp-server/src/tools/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,12 @@ export function register(server: McpServer, ctx: ToolContext): void {
parent_spec_task_id: located.info.parent_spec_task_id,
session_id: args.session_id,
});
scheduleAutoArchiveRetryIfNeeded(store, {
plan_slug: args.plan_slug,
parent_spec_task_id: located.info.parent_spec_task_id,
session_id: args.session_id,
outcome: autoArchive,
});

return {
content: [
Expand Down Expand Up @@ -1237,6 +1243,7 @@ function completedSpecRowCells(spec: Spec, specRowId: string): string[] {
interface AutoArchiveOutcome {
status: 'archived' | 'blocked' | 'error' | 'skipped';
reason?: string;
retry_after_ms?: number;
archived_path?: string;
merged_root_hash?: string;
applied?: number;
Expand All @@ -1251,6 +1258,7 @@ interface AutoArchiveOutcome {
* can land first if the lane wants explicit archival.
*/
const AUTO_ARCHIVE_GRACE_PERIOD_MS = 60_000;
const pendingAutoArchiveRetries = new Set<string>();

function runAutoArchiveIfReady(
store: MemoryStore,
Expand Down Expand Up @@ -1301,6 +1309,7 @@ function runAutoArchiveIfReady(
return {
status: 'skipped',
reason: 'auto_archive grace period pending',
retry_after_ms: AUTO_ARCHIVE_GRACE_PERIOD_MS - (now - latest),
};
}
}
Expand Down Expand Up @@ -1429,6 +1438,42 @@ function runAutoArchiveIfReady(
}
}

function scheduleAutoArchiveRetryIfNeeded(
store: MemoryStore,
args: {
plan_slug: string;
parent_spec_task_id: number | null;
session_id: string;
outcome: AutoArchiveOutcome;
},
): void {
if (args.parent_spec_task_id == null) return;
if (
args.outcome.status !== 'skipped' ||
args.outcome.reason !== 'auto_archive grace period pending'
) {
return;
}
const key = `${store.dbPath}:${args.parent_spec_task_id}:${args.plan_slug}`;
if (pendingAutoArchiveRetries.has(key)) return;
pendingAutoArchiveRetries.add(key);
const delay = Math.max(0, args.outcome.retry_after_ms ?? AUTO_ARCHIVE_GRACE_PERIOD_MS);
const timer = setTimeout(() => {
pendingAutoArchiveRetries.delete(key);
try {
ensurePlanAutoArchiveSweepSession(store);
runAutoArchiveIfReady(store, {
plan_slug: args.plan_slug,
parent_spec_task_id: args.parent_spec_task_id,
session_id: PLAN_AUTO_ARCHIVE_SWEEP_SESSION,
});
} catch {
// Best-effort: explicit task_plan_list / completion paths can retry.
}
}, delay);
timer.unref?.();
}

/**
* Resolve the parent spec task's `repo_root` to a directory that still
* exists on disk. When the task was created inside an agent worktree
Expand Down Expand Up @@ -1486,14 +1531,7 @@ function sweepCompletedPlansForAutoArchive(store: MemoryStore, repo_root?: strin
const total = counts.available + counts.claimed + counts.completed + counts.blocked;
if (total === 0 || counts.completed !== total) continue;
if (!sessionEnsured) {
if (!store.storage.getSession(PLAN_AUTO_ARCHIVE_SWEEP_SESSION)) {
store.startSession({
id: PLAN_AUTO_ARCHIVE_SWEEP_SESSION,
ide: 'plan-system',
cwd: null,
metadata: { source: 'plan-auto-archive-sweep' },
});
}
ensurePlanAutoArchiveSweepSession(store);
sessionEnsured = true;
}
runAutoArchiveIfReady(store, {
Expand All @@ -1507,6 +1545,16 @@ function sweepCompletedPlansForAutoArchive(store: MemoryStore, repo_root?: strin
}
}

function ensurePlanAutoArchiveSweepSession(store: MemoryStore): void {
if (store.storage.getSession(PLAN_AUTO_ARCHIVE_SWEEP_SESSION)) return;
store.startSession({
id: PLAN_AUTO_ARCHIVE_SWEEP_SESSION,
ide: 'plan-system',
cwd: null,
metadata: { source: 'plan-auto-archive-sweep' },
});
}

function latestSubtaskCompletedAt(
store: MemoryStore,
siblingTasks: Array<{ id: number }>,
Expand Down
35 changes: 34 additions & 1 deletion apps/mcp-server/test/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { MemoryStore, TaskThread, type WorktreeContentionReport } from '@colony/
import { createPlanWorkspace } from '@colony/spec';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildServer } from '../src/server.js';

let dataDir: string;
Expand Down Expand Up @@ -1390,6 +1390,39 @@ describe('task_plan auto-archive', () => {
}
});

it('archives automatically after the grace window without a list call', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-15T12:00:00Z'));
try {
await call<PublishResult>(
'task_plan_publish',
basicPublishArgs({ slug: 'auto-archive-grace-timer' }),
);
await claimAndComplete('auto-archive-grace-timer', 0, 'B', 'codex');
const last = await claimAndComplete('auto-archive-grace-timer', 1, 'C', 'claude');
expect(last.auto_archive.status).toBe('skipped');
expect(last.auto_archive.reason).toMatch(/grace/);
expect(existsSync(join(repoRoot, 'openspec/changes/auto-archive-grace-timer/CHANGE.md')))
.toBe(true);

await vi.advanceTimersByTimeAsync(60_000);

expect(existsSync(join(repoRoot, 'openspec/changes/auto-archive-grace-timer/CHANGE.md')))
.toBe(false);
const parentTask = store.storage
.listTasks(2000)
.find((t) => t.branch === 'spec/auto-archive-grace-timer');
expect(parentTask).toBeDefined();
if (parentTask) {
const archived = store.storage.taskObservationsByKind(parentTask.id, 'plan-archived', 10);
expect(archived).toHaveLength(1);
expect(archived[0]?.session_id).toBe('plan-auto-archive-sweep');
}
} finally {
vi.useRealTimers();
}
});

it('delta written then archive throws', async () => {
const slug = 'delta-archive-throws';
const published = await call<PublishResult>(
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,19 @@
## Why

Completed Queen plans with `auto_archive=false` defer archival for a short
grace window. The completion path returned `auto_archive grace period pending`,
but without a later `task_plan_list` call the plan could remain completed and
unarchived until health surfaced a recommendation.

## What Changes

Schedule a bounded retry when the final sub-task completion hits the grace
window. The retry reuses the existing three-way archive path, records the normal
`plan-archived` / `plan-archive-blocked` / `plan-archive-error` observations,
and dedupes timers per store + plan.

## Impact

Affected surface is MCP plan completion. Existing `task_plan_list` sweep remains
as a fallback. Timers are unref'd and best-effort, so explicit list/completion
paths can still retry after process restarts.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## ADDED Requirements

### Requirement: auto-archive-completed-queen-plans-when-remaining-subtasks-0 behavior
The MCP plan system SHALL automatically retry archiving a completed Queen plan
after the default grace window when the final sub-task completion deferred
archival.

#### Scenario: Completion reaches grace window
- **GIVEN** a Queen plan has no remaining incomplete sub-tasks
- **AND** the completion result reports `auto_archive grace period pending`
- **WHEN** the grace window elapses
- **THEN** the system retries the existing three-way archive path automatically
- **AND** records the normal `plan-archived` observation on success.

#### Scenario: Archive retry cannot merge cleanly
- **GIVEN** the automatic grace-window retry reaches a conflicting spec archive
- **WHEN** the three-way archive path reports conflicts
- **THEN** the system records the normal `plan-archive-blocked` observation
- **AND** later explicit archive/list paths can retry after manual repair.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
## 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-auto-archive-completed-queen-plans-when-2026-05-15-20-56`; branch=`agent/codex/auto-archive-completed-queen-plans-when-2026-05-15-20-56`; scope=`apps/mcp-server/src/tools/plan.ts`; action=`finish verification and cleanup`.
- Copy prompt: Continue `agent-codex-auto-archive-completed-queen-plans-when-2026-05-15-20-56` on branch `agent/codex/auto-archive-completed-queen-plans-when-2026-05-15-20-56`. Work inside the existing sandbox, review `openspec/changes/agent-codex-auto-archive-completed-queen-plans-when-2026-05-15-20-56/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/auto-archive-completed-queen-plans-when-2026-05-15-20-56 --base main --via-pr --cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-auto-archive-completed-queen-plans-when-2026-05-15-20-56`.
- [x] 1.2 Define normative requirements in `specs/auto-archive-completed-queen-plans-when-remaining-subtasks-0/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.
- `pnpm --filter @colony/mcp-server test -- plan.test.ts`
- `pnpm --filter @colony/mcp-server typecheck`
- `git diff --check`
- [x] 3.2 Run `openspec validate agent-codex-auto-archive-completed-queen-plans-when-2026-05-15-20-56 --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/<your-name>/<branch-slug> --base dev --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