mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
test(chaos): count an M1 subtask stall only when its recovery needs the board (#20178)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The M1 chaos oracle checks that a parent gets exactly one `issue_blocker_stalled` wake per stalled subtask (#20147) > - The product wakes a parent only for a stall that needs the board. The harness recorded a stall for any blocked subtask, whatever its latest recovery action > - In the Daytona M1 wave, a subtask blocked twice on agent- and system-owned recovery that resolved by itself. The harness counted two stalls and failed the parent for getting no wake, a false failure > - This pull request records a stall only when the subtask's latest recovery action needs the board, matching the product's definition > - The benefit is that `parent_woken_once_per_stall` fails only for a wake the product owed ## Linked Issues or Issue Description Refs #20147, #20098 **What happened?** In wave `w-20261109T041248-s9501`, t10's subtask 195b5fd7 was blocked twice. Action 56d036e0 was agent-owned (`native_session_interrupted`) and was cancelled. Action 53eb91b0 was system-owned (`native_continuation_requires_reconciliation`) and resolved with outcome cancelled. Neither needed the board, so the product correctly sent no stall wake. The harness still failed `parent_woken_once_per_stall` with 2 stalls and 0 wakes. **Expected behavior** A stall counts when the subtask's latest recovery action is: - open (active or escalated) and board-owned, or escalated; or - board-owned and resolved with outcome blocked, with the subtask still blocked. That is the definition the product's blocker-stall wake uses. ## What Changed - `observeChildStalls` reads the parent's `stalledDependencies` from `GET /api/issues/:id`. That is the product's own stalled-dependency view (`listStalledDependencies`, built with the same `readStalls` the blocker-stall wake uses). - Each `relation: "child"` entry is recorded as a stall (`stalledIssueId`, `recoveryAction.id`). The harness no longer infers stalls from `/recovery-actions`. That endpoint lists only open actions and selected holds, and can omit a board action resolved with outcome blocked. - Clicking the board Retry on a blocked subtask is unchanged: it still happens whenever the product offers Retry, and it still counts as a needed Retry. - Tests: - a stall the product lists is recorded; - for t10's two non-board recovery states, the product lists nothing, so no stall is recorded, and the subtask's Retry is still clicked. ## Verification - `npx vitest run` in `tests/restart-chaos`: 212 passed. - `node scripts/typecheck-test-projects.mjs tests/restart-chaos`: clean. ## Risks - The harness checks `stalledDependencies` every 10 s. A stall that appears and clears inside one interval is not recorded, so its wake goes unchecked. It is never reported as a false failure. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
95bf90bd2c
commit
0ddc9f1ef1
2 files changed
+34
-7
No files matched your search
@@ -111,10 +111,14 @@ describe("TaskObserver", () => {
|
||||
it("clicks the board Retry on a blocked subtask, as a user would, and keeps watching the parent", async () => {
|
||||
let childStatus = "blocked";
|
||||
const base = fakeApi({ "parentIds=": () => [{ id: "c1", parentId: "i1", status: "done" }, { id: "c2", parentId: "i1", status: childStatus }],
|
||||
"/documents/": () => null, "/recovery-actions": () => ({ active: { id: "a1" }, actions: [] }), "/api/issues/c2": () => ({ id: "c2", status: childStatus,
|
||||
"/documents/": () => null, "/recovery-actions": () => ({ active: { id: "a1", status: "active", ownerType: "board" }, actions: [] }), "/api/issues/c2": () => ({ id: "c2", status: childStatus,
|
||||
interruption: { canRetry: childStatus === "blocked", observedRunId: "child-run" } }) }, "blocked");
|
||||
const childRetries: string[] = [];
|
||||
const api = { ...base.api, get: base.api.get.bind(base.api),
|
||||
const api = { ...base.api, async get(route: string) {
|
||||
if (route === "/api/issues/i1") return { id: "i1", status: "blocked", stalledDependencies: childStatus === "blocked"
|
||||
? [{ stalledIssueId: "c2", relation: "child", recoveryAction: { id: "a1", status: "active", ownerType: "board" } }] : [] };
|
||||
return base.api.get(route);
|
||||
},
|
||||
async post(route: string, body: unknown) {
|
||||
if (route === "/api/issues/c2/retry") { childRetries.push(route); childStatus = "in_progress"; return { accepted: true }; }
|
||||
return (base.api as unknown as { post(route: string, body: unknown): Promise<unknown> }).post(route, body);
|
||||
@@ -214,6 +218,27 @@ describe("TaskObserver", () => {
|
||||
expect(artifacts.stop?.runsStartedBeforeContinue).toEqual(["r2"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["agent-owned interrupted session", { id: "a-agent", status: "cancelled", ownerType: "agent" }],
|
||||
["system reconciliation resolved as cancelled", { id: "a-system", status: "resolved", ownerType: "system", outcome: "cancelled" }],
|
||||
])("records no stall for a blocked subtask the product does not list as stalled (%s), but still clicks its Retry", async (_name, action) => {
|
||||
let childStatus = "blocked";
|
||||
const base = fakeApi({ "parentIds=": () => [{ id: "c1", parentId: "i1", status: "done" }, { id: "c2", parentId: "i1", status: childStatus }],
|
||||
"/documents/": () => null, "/recovery-actions": () => ({ active: null, actions: [action] }),
|
||||
"/api/issues/i1": route => route === "/api/issues/i1" ? { id: "i1", status: "blocked", stalledDependencies: [] } : null,
|
||||
"/api/issues/c2": () => ({ id: "c2", status: childStatus, interruption: { canRetry: childStatus === "blocked", observedRunId: "child-run" } }) }, "blocked");
|
||||
const childRetries: string[] = [];
|
||||
const api = { ...base.api, get: base.api.get.bind(base.api), async post(route: string, body: unknown) {
|
||||
if (route === "/api/issues/c2/retry") { childRetries.push(route); childStatus = "in_progress"; return { accepted: true }; }
|
||||
return (base.api as unknown as { post(route: string, body: unknown): Promise<unknown> }).post(route, body);
|
||||
} } as unknown as ChaosApi;
|
||||
const observer = new TaskObserver({ ...plan, workload: "M1", followUpAfterMs: null }, api, lab, new Timeline(null), ready);
|
||||
await observer.launch();
|
||||
await observer.poll(observer.record.createdAt! + 1_000);
|
||||
expect(observer.record.childStalls ?? []).toEqual([]);
|
||||
expect(childRetries).toEqual(["/api/issues/c2/retry"]);
|
||||
});
|
||||
|
||||
it("closes an open task as unfinished at interrupt, so the wave grades it as a failure", async () => {
|
||||
const { api } = fakeApi({}, "in_progress");
|
||||
const timeline = new Timeline(null);
|
||||
|
||||
@@ -587,11 +587,13 @@ export class TaskObserver {
|
||||
const rows = await this.api.get<Json[]>(`/api/companies/${this.lab.companyId}/issues?view=index&parentIds=${encodeURIComponent(this.record.issueId)}&limit=20`)
|
||||
.catch(() => null);
|
||||
const blocked = (Array.isArray(rows) ? rows : []).filter(row => row.parentId === this.record.issueId && row.status === "blocked").map(row => String(row.id));
|
||||
for (const childIssueId of blocked) {
|
||||
const recovery = await this.api.get<Json>(`/api/issues/${childIssueId}/recovery-actions`).catch(() => null);
|
||||
const actions = Array.isArray(recovery?.actions) ? recovery.actions as Json[] : [];
|
||||
const latest = (recovery?.active as Json | null | undefined) ?? actions[0] ?? null;
|
||||
const recoveryActionId = typeof latest?.id === "string" ? latest.id : null;
|
||||
const parent = await this.api.get<Json>(`/api/issues/${this.record.issueId}`).catch(() => null);
|
||||
const stalled = Array.isArray(parent?.stalledDependencies) ? parent.stalledDependencies as Json[] : [];
|
||||
for (const dependency of stalled) {
|
||||
if (dependency.relation !== "child" || typeof dependency.stalledIssueId !== "string") continue;
|
||||
const action = (dependency.recoveryAction ?? null) as Json | null;
|
||||
const childIssueId = dependency.stalledIssueId;
|
||||
const recoveryActionId = typeof action?.id === "string" ? action.id : null;
|
||||
const stalls = this.record.childStalls ??= [];
|
||||
if (!stalls.some(stall => stall.childIssueId === childIssueId && stall.recoveryActionId === recoveryActionId)) {
|
||||
stalls.push({ childIssueId, recoveryActionId });
|
||||
|
||||
Reference in new issue
Block a user