mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
## Thinking Path > - Paperclip manages agent work through tasks, projects, and routines. > - Daily QA must exercise the staging app through normal browser and API surfaces. > - A schedule must create real work and retain its result and history. > - Pausing that schedule must prevent the next invocation. > - The routine journey in #18652 passed in QA, while its separate completion journey has not passed. > - This PR delivers the routine test independently and preserves its measured flow and assertions. ## Linked Issues or Issue Description Refs: #18652. This is the routine-only delivery from that PR. The original completion implementation and its failed attempts remain on #18652. ## What Changed - Add the target-independent `routine-scheduled-run` journey with one native run and seven required checks. - Start from the measured routine source `ac4efb7c6f4782ea806661683c656fd2b4cc3189`. Keep its request, seven success assertions, one native-run limit, and 65-second pause check. - Bound each observed GET by the original remaining journey deadline. Preserve the normal API timeout for other callers. Keep the original routine error if final trigger disabling also fails, save a closed secondary receipt, and fail an otherwise successful journey when disabling fails. - Narrow the shared output-reader type to its one-argument GET capability without changing its runtime code. - Register only this case in the catalog, types, feature associations, dispatcher, and definition hash. Keep the strict catalog expectation in sync. - Retain the real Routines component controls and the existing project-flow coverage for the shared browser helper. - Document resource cleanup and the 65-second pause observation. Preserve the current routine map content. ## Verification - The original source passed [QA staging run 41567574185](https://pages.paperclip.ing/evals-qa-smoke-41567574185-1/). TestOps launch 1657 / result 3431 maps to case 718. All seven checks and five phases passed. One scheduler-bound native run, saved output attribution, the full pause window, and cleanup were verified. - Current source `8f62137f130f59fd3e39276e3d60185ddff24517` passed [fresh QA staging run 41582706162](https://pages.paperclip.ing/evals-qa-smoke-41582706162-1/) against Core `09162fe893b464dd2ccdafd14bd3ef1611202a86`. TestOps launch 1855. All seven checks, five phases, and all three workflow jobs passed. The run used one scheduler-bound native turn and observed 65.352 seconds after pause and completion. Canonical evidence collection and normal cleanup completed, with no active operations and the owned agent terminal. - This fresh pass covers the reviewed deadline and failure-reporting changes in the current source. The earlier ac4 pass and the byte-preserving b940 extraction remain historical records under their original conditions; neither failed attempts nor prior source conditions are regraded. - Causal controls: six assertions failed on the old behavior, then all 62 focused controls passed after the repair. Local: 834 related support tests, 41 shared chat/output-reader tests, and 26 unchanged real component controls passed. Runner E2E typecheck, recursive typecheck, build, maps, token gates, and `git diff --check` passed. The temporary output-reader type mismatch exposed by typecheck was corrected by narrowing the capability type. - The first restricted build/typecheck invocations failed because local tsx IPC was denied. A restricted support run also lacked the report CLI output. The same source passed with normal owned local IPC allowed; the original logs remain retained. - The original head's hosted e2e shard 2/8 failed at the unchanged mobile preference frame-count test: expected more than five frames, observed five. The cause is unknown. Its original log remains retained; it is not labeled a proven flake. No CI rerun was requested. - Current-head full CI run 41580423918, attempt 1, passed. The fresh review is 5/5 with zero unresolved threads. The broad local suite was not repeated. The earlier full local run recorded in #18643 had four failures and did not reach 164 serialized groups; this PR does not claim an all-local pass. - Prior failed routine attempts remain recorded. They exposed composer focus, collapsed schedule controls, pending browser events, and Markdown serialization problems. The measured later pass does not regrade those attempts. ## Risks - The shared browser-event helper also serves task creation and project creation. Its regression tests and existing consumers remain included. - The staging pass qualifies the captured source and configuration. The coordinator retains source merging, daily pinning, and plan selection. - The repository test and the unpassed completion test are not registered by this PR. ## Model Used OpenAI GPT-6 through Codex, with tool use and code execution. The exact deployment ID and context window are not exposed by this session. ## 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 issue/PR references) --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
90 lines
5.5 KiB
TypeScript
90 lines
5.5 KiB
TypeScript
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { ConnectionFailure } from "../connection-config.js";
|
|
import { createObservedReader } from "./observation-read.js";
|
|
|
|
afterEach(() => vi.useRealTimers());
|
|
describe("bounded observation reads", () => {
|
|
it("rejects a successful in-flight read that finishes after the original deadline", async () => {
|
|
vi.useFakeTimers();
|
|
const get = vi.fn(() => new Promise(resolve => setTimeout(() => resolve({ id: "late" }), 75)));
|
|
const reader = createObservedReader({ get } as any, Date.now() + 50);
|
|
const result = reader.get("/api/issues/owned").then(value => ({ value, error: undefined }), error => ({ error }));
|
|
await vi.advanceTimersByTimeAsync(75);
|
|
expect((await result).error).toMatchObject({ code: "observation_read_deadline" });
|
|
expect(get).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("ends an in-flight read at the original deadline even when the transport does not settle", async () => {
|
|
vi.useFakeTimers();
|
|
const get = vi.fn(() => new Promise(() => {}));
|
|
let failure: unknown;
|
|
const result = createObservedReader({ get } as any, Date.now() + 50).get("/api/issues/owned").catch(error => { failure = error; });
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
expect(failure).toMatchObject({ code: "observation_read_deadline" });
|
|
await result;
|
|
expect(get).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("passes the remaining deadline to the transport without extending its normal timeout", async () => {
|
|
const get = vi.fn().mockResolvedValue({ id: "owned" });
|
|
await createObservedReader({ get }, Date.now() + 50).get("/api/issues/owned");
|
|
expect(get.mock.calls[0]?.[1]?.timeoutMs).toBeGreaterThan(0);
|
|
expect(get.mock.calls[0]?.[1]?.timeoutMs).toBeLessThanOrEqual(50);
|
|
await createObservedReader({ get }, Date.now() + 100_000).get("/api/issues/owned");
|
|
expect(get.mock.calls[1]?.[1]).toEqual({ timeoutMs: 30_000 });
|
|
});
|
|
it.each(["api_request_failed", "api_status_408", "api_status_429", "api_status_502", "api_status_503", "api_status_504"])("retains and retries %s without replaying work", async code => {
|
|
vi.useFakeTimers();
|
|
const get = vi.fn().mockRejectedValueOnce(new ConnectionFailure(code)).mockResolvedValue({ id: "owned" });
|
|
const reader = createObservedReader({ get }, Date.now() + 2000), result = reader.get("/api/issues/owned");
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
await expect(result).resolves.toEqual({ id: "owned" });
|
|
expect(get).toHaveBeenCalledTimes(2);
|
|
expect(reader.failures).toEqual([{ route: "/api/issues/owned", code }]);
|
|
});
|
|
it.each(["api_status_401", "api_status_403", "api_status_404", "api_status_409", "api_status_422", "api_status_500", "invalid_api_route"])("preserves %s as a failure", async code => {
|
|
const error = new ConnectionFailure(code), get = vi.fn().mockRejectedValue(error);
|
|
const reader = createObservedReader({ get }, Date.now() + 2000);
|
|
await expect(reader.get("/api/issues/owned")).rejects.toBe(error);
|
|
expect(get).toHaveBeenCalledTimes(1);
|
|
expect(reader.failures).toEqual(code === "invalid_api_route" ? [] : [{ route: "/api/issues/owned", code }]);
|
|
});
|
|
it("saves a fatal status receipt to private evidence without retrying", async () => {
|
|
const directory = await mkdtemp(path.join(os.tmpdir(), "qa-observation-evidence-"));
|
|
const error = new ConnectionFailure("api_status_422"), get = vi.fn().mockRejectedValue(error);
|
|
const reader = createObservedReader({ get }, Date.now() + 2000);
|
|
try {
|
|
await expect(reader.get("/api/issues/owned/documents/result")).rejects.toBe(error);
|
|
await reader.preserve(directory);
|
|
expect(JSON.parse(await readFile(path.join(directory, "observation-read-failures.json"), "utf8"))).toEqual({
|
|
failures: [{ route: "/api/issues/owned/documents/result", code: "api_status_422" }],
|
|
});
|
|
expect(get).toHaveBeenCalledTimes(1);
|
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
});
|
|
it("excludes arbitrary error text from the closed receipt", async () => {
|
|
const error = new ConnectionFailure("api_status_422 body contains private values"), get = vi.fn().mockRejectedValue(error);
|
|
const reader = createObservedReader({ get }, Date.now() + 2000);
|
|
await expect(reader.get("/api/issues/owned")).rejects.toBe(error);
|
|
expect(reader.failures).toEqual([]);
|
|
expect(get).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("keeps a shorter phase deadline when that phase begins", async () => {
|
|
vi.useFakeTimers(); let deadlineAt = Date.now() + 100;
|
|
const get = vi.fn().mockRejectedValue(new ConnectionFailure("api_status_504"));
|
|
const reader = createObservedReader({ get }, () => deadlineAt), result = reader.get("/api/issues/owned");
|
|
const assertion = expect(result).rejects.toMatchObject({ code: "observation_read_deadline" });
|
|
await vi.advanceTimersByTimeAsync(100); await assertion;
|
|
expect(get).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("fails at the original deadline instead of turning missing observations into a pass", async () => {
|
|
vi.useFakeTimers();
|
|
const get = vi.fn().mockRejectedValue(new ConnectionFailure("api_status_503"));
|
|
const reader = createObservedReader({ get }, Date.now() + 500), result = reader.get("/api/issues/owned");
|
|
const assertion = expect(result).rejects.toMatchObject({ code: "observation_read_deadline" });
|
|
await vi.advanceTimersByTimeAsync(500); await assertion;
|
|
expect(get).toHaveBeenCalledTimes(1); expect(reader.failures).toHaveLength(1);
|
|
});
|
|
});
|