Skip to content
Closed
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
588 changes: 588 additions & 0 deletions .agentplane/tasks/202608241454-4JK4MP/README.md

Large diffs are not rendered by default.

580 changes: 580 additions & 0 deletions .agentplane/tasks/202608241454-4JK4MP/blueprint/resolved-snapshot.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions .agentplane/tasks/202608241454-4JK4MP/pr/diffstat.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
...n-cli.core.task-advance-effect-recovery.test.ts | 38 +++++++---
...un-cli.core.task-advance.blocked-result.test.ts | 82 ++++++++++++++++++++++
.../src/commands/task/external-agent-supervisor.ts | 71 +++++++++++++++++--
3 files changed, 176 insertions(+), 15 deletions(-)
36 changes: 36 additions & 0 deletions .agentplane/tasks/202608241454-4JK4MP/pr/github-body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
Task: `202608241454-4JK4MP`
Title: Allow replacement after a rejected external-agent result
Canonical task record: `.agentplane/tasks/202608241454-4JK4MP/README.md`

## Summary

Allow replacement after a rejected external-agent result

Fix the external-agent supervisor so a schema-valid result that is durably recorded but later rejected by pre-apply implementation-authority validation does not permanently lock the exchange or replay the same failure. Preserve immutable accepted/consumed result semantics and effect-in-doubt safety. Add a regression for completed implementation with no workspace change followed by a corrected replacement exchange, plus conflicting replay coverage. This is a v0.7.8 release blocker discovered by task 202608241434-NCQYZ4; it must remain a separate code Task and merge before a fresh release attempt.

## Scope

- In scope: Fix the external-agent supervisor so a schema-valid result that is durably recorded but later rejected by pre-apply implementation-authority validation does not permanently lock the exchange or replay the same failure. Preserve immutable accepted/consumed result semantics and effect-in-doubt safety. Add a regression for completed implementation with no workspace change followed by a corrected replacement exchange, plus conflicting replay coverage. This is a v0.7.8 release blocker discovered by task 202608241434-NCQYZ4; it must remain a separate code Task and merge before a fresh release attempt.
- Out of scope: unrelated refactors not required for "Allow replacement after a rejected external-agent result".

## Verification

- State: pending
- Note: Not recorded yet.
- Canonical workflow state lives in the task README.

<details>
<summary>Raw evidence</summary>

- Updated: 2026-08-24T15:04:21.249Z
- Branch: task/202608241454-4JK4MP/allow-replacement-after-a-rejected-external-agen
- Head: computed live by `agentplane pr check` / `agentplane integrate`

```text
...n-cli.core.task-advance-effect-recovery.test.ts | 38 +++++++---
...un-cli.core.task-advance.blocked-result.test.ts | 82 ++++++++++++++++++++++
.../src/commands/task/external-agent-supervisor.ts | 71 +++++++++++++++++--
3 files changed, 176 insertions(+), 15 deletions(-)
```

</details>
1 change: 1 addition & 0 deletions .agentplane/tasks/202608241454-4JK4MP/pr/github-title.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
🚧 4JK4MP task: Allow replacement after a rejected external-agent result [202608241454-4JK4MP]
21 changes: 21 additions & 0 deletions .agentplane/tasks/202608241454-4JK4MP/pr/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"base": "main",
"branch": "task/202608241454-4JK4MP/allow-replacement-after-a-rejected-external-agen",
"created_at": "2026-08-24T15:04:21.249Z",
"diffstat_sha256": "sha256:2ed73b157e652bc5f88fb360104e7bfd9ae823d195cb07145e747c2e337b5cf3",
"last_verified_at": null,
"provider": {
"hostname": "github.com",
"kind": "github",
"remote": "origin",
"schema_version": 1,
"source_project": "basilisk-labs/agentplane",
"target_project": "basilisk-labs/agentplane"
},
"schema_version": 1,
"task_id": "202608241454-4JK4MP",
"updated_at": "2026-08-24T15:04:21.249Z",
"verify": {
"status": "skipped"
}
}
39 changes: 39 additions & 0 deletions .agentplane/tasks/202608241454-4JK4MP/pr/review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# PR Review

Created: 2026-08-24T15:04:21.249Z

## Task

- Task: `202608241454-4JK4MP`
- Title: Allow replacement after a rejected external-agent result
- Status: DOING
- Branch: `task/202608241454-4JK4MP/allow-replacement-after-a-rejected-external-agen`
- Canonical task record: `.agentplane/tasks/202608241454-4JK4MP/README.md`

## Verification

- State: pending
- Note: Not recorded yet.
- Canonical workflow state lives in the task README.

## Handoff Notes

- No handoff notes recorded yet. Use `agentplane pr note ...` to append one.

<!-- BEGIN AUTO SUMMARY -->
<details>
<summary>Raw evidence</summary>

- Updated: 2026-08-24T15:04:21.249Z
- Branch: task/202608241454-4JK4MP/allow-replacement-after-a-rejected-external-agen
- Head: computed live by `agentplane pr check` / `agentplane integrate`

```text
...n-cli.core.task-advance-effect-recovery.test.ts | 38 +++++++---
...un-cli.core.task-advance.blocked-result.test.ts | 82 ++++++++++++++++++++++
.../src/commands/task/external-agent-supervisor.ts | 71 +++++++++++++++++--
3 files changed, 176 insertions(+), 15 deletions(-)
```

</details>
<!-- END AUTO SUMMARY -->
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ type AgentPacket = {
};

async function createTask(root: string): Promise<string> {
await execFileAsync("git", ["commit", "--allow-empty", "-m", "test: initialize repository"], {
cwd: root,
});
const io = captureStdIO();
try {
const code = await runCli([
Expand Down Expand Up @@ -714,7 +717,16 @@ describe("task advance effect recovery", () => {
try {
const code = await runCli(["task", "advance", taskId, "--agent-json", "--root", root]);
expect(code, io.stderr).toBe(0);
expect(JSON.parse(io.stdout)).toMatchObject({ action: { kind: "approval_required" } });
const recoveredPacket = JSON.parse(io.stdout) as AgentPacket & {
action: { kind: string; instruction: string };
stop: { reason: string };
};
expect(recoveredPacket, JSON.stringify(recoveredPacket, null, 2)).toMatchObject({
action: { kind: "agent_episode" },
stop: { reason: "semantic_boundary" },
});
expect(recoveredPacket.action.instruction).toContain("TaskPlanProposal");
expect(recoveredPacket.exchange?.directory).not.toBe(packet.exchange?.directory);
} finally {
io.restore();
}
Expand Down Expand Up @@ -823,23 +835,33 @@ describe("task advance effect recovery", () => {
root,
]);
expect(code, io.stderr).toBe(0);
expect(JSON.parse(io.stdout)).toMatchObject({
action: { kind: "framework_transition" },
stop: { reason: "control_plane_boundary" },
const recoveredPacket = JSON.parse(io.stdout) as AgentPacket & {
action: { kind: string; instruction: string };
stop: { reason: string };
};
expect(recoveredPacket, JSON.stringify(recoveredPacket, null, 2)).toMatchObject({
action: { kind: "agent_episode" },
stop: { reason: "semantic_boundary" },
});
expect(recoveredPacket.action.instruction).toContain("TaskPlanProposal");
expect(recoveredPacket.exchange?.directory).not.toBe(issued.exchange?.directory);
} finally {
io.restore();
}
expect(
await readFile(path.join(root, ".agentplane", "tasks", taskId, "README.md"), "utf8"),
).toContain(plan);
expect(validateSupervisorExecutionEpisodeJournal(await store.read())).toMatchObject({
const recoveredJournal = validateSupervisorExecutionEpisodeJournal(await store.read());
expect(recoveredJournal).toMatchObject({
status: "running",
stop: null,
cursor: { phase: "ready", operation_key: null },
usage: journal.usage,
operations: [{ role: "PLANNER", status: "completed" }],
cursor: { phase: "intent_recorded" },
});
expect(recoveredJournal.operations.slice(-2)).toMatchObject([
{ role: "PLANNER", status: "completed" },
{ role: "PLANNER", status: "intent" },
]);
expect(recoveredJournal.usage.agent_runs).toBe(journal.usage.agent_runs + 1);
});

it("rejects a late planning result when a different plan awaits approval", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,88 @@ describe("runCli task advance blocked results", { timeout: 180_000 }, () => {
expect(resumed.state_fingerprint).not.toBe(issued.state_fingerprint);
});

it("retires a no-change implementation result and accepts a corrected replacement", async () => {
const { taskId, taskWorktree } = await prepareBlockedResultTask({
title: "Replace rejected no-change result",
plan: "Replace a completed implementation result rejected before any apply effect.",
slug: "replace-rejected-no-change-result",
});
const issued = await readAgentPacket(taskWorktree, taskId);
if (!issued.exchange) throw new Error("expected an implementation exchange");
const rejectedResultPath = await writeCompletedResult(
issued,
"Implementation completed without a workspace change.",
);

const rejected = await returnAgentResult(taskWorktree, taskId, rejectedResultPath);
expect(rejected.code).not.toBe(0);
expect(rejected.stderr).toContain(
"Completed implementation result produced no supervisor-observed workspace change.",
);
expect(rejected.stderr).toContain("--replacement");
const rejectedExchangePath = path.join(issued.exchange.directory, "exchange.json");
expect(JSON.parse(await readFile(rejectedExchangePath, "utf8"))).toMatchObject({
status: "retired",
});
const journalPath = await resolveSupervisorExecutionEpisodePath({
git_root: taskWorktree,
task_id: taskId,
});
const failedJournal = validateSupervisorExecutionEpisodeJournal(
JSON.parse(await readFile(journalPath, "utf8")) as unknown,
);
const failedOperation = failedJournal.operations.at(-1);
expect(failedJournal).toMatchObject({
status: "stopped",
stop: { reason: "operation_failed", operation_key: failedOperation?.operation_key },
});
expect(failedOperation).toMatchObject({ status: "failed" });

const conflicting = JSON.parse(await readFile(rejectedResultPath, "utf8")) as {
result: { summary: string };
};
conflicting.result.summary = "Conflicting replacement written into the retired exchange.";
await writeFile(rejectedResultPath, `${JSON.stringify(conflicting, null, 2)}\n`, "utf8");
const replay = await returnAgentResult(taskWorktree, taskId, rejectedResultPath);
expect(replay.code).not.toBe(0);
expect(replay.stderr).toContain("exchange was retired");

const replacementIo = captureStdIO();
let replacement: AgentPacket;
try {
expect(
await runCli([
"task",
"advance",
taskId,
"--replacement",
"--agent-json",
"--root",
taskWorktree,
]),
replacementIo.stderr,
).toBe(0);
replacement = JSON.parse(replacementIo.stdout) as AgentPacket;
} finally {
replacementIo.restore();
}
expect(replacement.action.kind).toBe("agent_episode");
expect(replacement.exchange?.result_path).not.toBe(rejectedResultPath);
expect(replacement.transition_id).not.toBe(issued.transition_id);
expect(replacement.exchange?.directory).not.toBe(issued.exchange.directory);
Comment on lines +692 to +695

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exercise the corrected result after issuing the replacement

This regression stops after confirming that a replacement exchange was issued, despite its name and the task acceptance criteria claiming that a corrected replacement is accepted. It never creates an allowed workspace change, writes a completed result to replacement.exchange.result_path, or returns that result through task advance, so a failure in the replacement acceptance/consumption path could leave the feature broken while this test still passes.

Useful? React with 👍 / 👎.

const replacementJournal = validateSupervisorExecutionEpisodeJournal(
JSON.parse(await readFile(journalPath, "utf8")) as unknown,
);
expect(replacementJournal).toMatchObject({
status: "running",
cursor: { phase: "intent_recorded" },
});
expect(replacementJournal.operations.slice(-2)).toMatchObject([
{ status: "failed" },
{ status: "intent", replacement_of_operation_key: failedOperation?.operation_key },
]);
});

it("rejects workspace changes returned with a blocked branch result", async () => {
const { taskId, taskWorktree } = await prepareBlockedResultTask({
title: "Blocked result with workspace changes",
Expand Down
71 changes: 64 additions & 7 deletions packages/agentplane/src/commands/task/external-agent-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,17 @@ function assertReadOnlyReturnFresh(opts: {
}
}

const NO_IMPLEMENTATION_CHANGE_REJECTION =
"Completed implementation result produced no supervisor-observed workspace change.";

function isPreApplyImplementationResultRejection(error: unknown): error is CliError {
return (
error instanceof CliError &&
error.code === "E_VALIDATION" &&
error.message === NO_IMPLEMENTATION_CHANGE_REJECTION
);
}

export async function acceptExternalAgentResult(opts: {
ctx: CommandCtx;
command: CommandContext;
Expand Down Expand Up @@ -459,13 +470,59 @@ export async function acceptExternalAgentResult(opts: {
assertReadOnlyReturnFresh({ exchange, decision: current });
}
if (!(alreadyApplied && exchange.purpose === "planning")) {
await applyAcceptedExternalAgentResult({
command: checkoutCommand,
decision: current,
exchange,
work_order: workOrder,
envelope,
});
try {
await applyAcceptedExternalAgentResult({
command: checkoutCommand,
decision: current,
exchange,
work_order: workOrder,
envelope,
});
} catch (error) {
if (intent.state !== "issued" || !isPreApplyImplementationResultRejection(error)) {
throw error;
}
const failed = completeSupervisorExecutionEpisode({
journal: issuedJournal,
operation_key: operation.operation_key,
result: {
classification: "external_agent_result_rejected_before_apply",
error: error.message,
result_digest: resultDigest,
},
failed: true,
});
if (!(await store.compareAndSwap(issuedJournal.digest, failed))) {
throw new CliError({
code: "E_RUNTIME",
message:
"External-agent supervisor changed while recording a pre-apply result rejection.",
});
}
exchange = {
...exchange,
status: "retired",
updated_at: new Date().toISOString(),
};
await writeExternalAgentExchange(paths.exchange, exchange);
throw new CliError({
code: "E_VALIDATION",
message:
`${error.message} AgentPlane retired the rejected result before any apply effect; ` +
`run: agentplane task advance ${opts.task_id} --replacement --agent-json`,
context: {
task_id: opts.task_id,
exact_argv: [
"agentplane",
"task",
"advance",
opts.task_id,
"--replacement",
"--agent-json",
],
},
});
}
}
const after = await refreshExternalAgentRoute({
cwd: exchange.checkout,
Expand Down