mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents use execution workspaces to retain their files and Git branches. > - A reaper closes eligible workspaces after their task tree is terminal. > - The reaper scanned every heartbeat tick and read Git before cheap eligibility checks. > - Permanent scans also query idle databases when no cleanup work exists. > - This pull request wakes cleanup after transaction settlement and restores bounded work at startup. > - The benefit is fewer scans while fresh cleanup guards continue to protect retained work. ## Linked Issues or Issue Description Related work: Refs #13619, Refs #11753. Those PRs bound scans or back off unchanged candidates. This PR uses the transaction work coordinator to remove the permanent reaper tick. It overlaps their early terminal-state filtering but does not change their runtime restart behavior or introduce a second Git scheduler cache. **What existing behavior does this improve?** Terminal workspace cleanup and Git inspection for workspace detail reads. **Subsystem affected** Server orchestration, execution workspaces, and idle sleep admission. **Current behavior** The heartbeat tick scans workspace candidates every thirty seconds. It runs Git before rejecting nonterminal trees, cooldowns, and active runs. Display inspections expire after five seconds. **Proposed behavior** Recover workspace rows in bounded startup pages. Wake after relevant issue, workspace, and pull-request work-product transactions settle. Schedule the terminal tree cooldown or a five-minute eligibility retry. Back off unchanged candidates across unrelated wakes, and invalidate their backoff after relevant committed changes. Park empty and nonterminal-only lanes. Reuse display inspections for thirty seconds. Read fresh Git before destructive cleanup. **Reason and benefit** Avoid recurring database and Git work when no cleanup is possible. Retain transaction admission holds, restart recovery, and fresh deletion checks. Retention eligibility can wait for the next host wake; active and failed recovery work still blocks sleep. **Breaking changes** No schema or API shape changes. Display Git state can lag by thirty seconds. Cleanup eligibility retries can take five minutes. ## What Changed - Add a terminal-workspace worker to the existing transaction work coordinator and scheduler. - Signal relevant writes before mutation on the owning transaction. Outer transaction settlement wakes cleanup. - Derive cooldown deadlines from the full company-scoped terminal task tree. Continue bounded recovery pages without a permanent sweep. - Bound unchanged candidate inspections to five minutes, retain at most 256 entries, and invalidate affected entries with scoped transaction hints. Repeated wakes preserve the earliest inspection deadline. - Collect deadlines from task trees already read by bounded pages. Restore the cursor after a failed page and rescan commits received during a rotation. - Check terminal state, cooldown, and active runs before Git. Preserve lifecycle locks, generation fences, and fresh final checks. - Keep failed or incomplete recovery visible to idle admission. Preserve the passive retention exception. - Extend the bounded display cache to thirty seconds and document the scheduling and wake limits. ## Verification - Focused workspace, coordinator, cache, and local sleep suites: 111 tests passed with real local PostgreSQL and Git worktrees where applicable. - Final worker, display cache, and local sleep suites: 28 tests passed. - The regressions prove three ineligible workspaces invoke zero Git scans, cached clean display state cannot remove new untracked files, and real producer notifications wait for outer transaction settlement. - `pnpm -r typecheck` and `pnpm build` passed locally. Map and module-boundary checks passed. - Work-product and sleep-safety suites: 221 tests passed. Reopen and issue-tree control suites: 46 tests passed. Final backoff, workspace, worker, and work-product suites: 87 tests passed. - Final workspace and worker suites: 81 tests passed. Deadline selection performs zero database reads. A failed full page retries its unread terminal workspace before recovery can park. - Full CI coverage passed on head `2c397970f7`: general tests, serialized server suites, runner verification, E2E shards, typecheck, build, and the final verification gate. The duplicate serial local `pnpm test:run` was stopped after full CI coverage passed; it is not reported as a completed local pass. - Greptile: 5/5 with both findings resolved. The security scan also passed. No failing or pending PR checks remain. ## Risks - Display state can be stale for thirty seconds. Destructive guards bypass the display cache. - Undelivered or otherwise ineligible terminal work retries after five minutes instead of each heartbeat tick. - Transaction signals cover the owning `createDb` scope. Direct SQL and independent server processes require startup recovery. - Local cooldown timers are maintenance hints. They do not establish an external wake alarm or whole-instance sleep safety. Existing blockers remain in force; failed bounded recovery keeps the host awake. ## Model Used OpenAI GPT-6, model ID `gpt-6`, through Codex. Reasoning, repository inspection, shell execution, and browser tools assisted the change. The runtime does not expose the context-window size. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
796 lines
39 KiB
TypeScript
796 lines
39 KiB
TypeScript
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, subscribeDatabaseWork } from "@paperclipai/db";
|
|
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
|
|
import { execFileSync } from "node:child_process";
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { mkdtemp, rm, stat, readFile, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import { pathToFileURL } from "node:url";
|
|
import { assertExecutionWorkspaceRootReadAccess } from "../services/execution-workspace-repositories.js";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
authUsers,
|
|
companyMemberships,
|
|
issueAccessGrants,
|
|
companies,
|
|
createDb,
|
|
executionWorkspaces,
|
|
issues,
|
|
projectWorkspaces,
|
|
projects,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import {
|
|
EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY,
|
|
EXECUTION_WORKSPACE_REOPEN_FAILED_REASON,
|
|
EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY,
|
|
EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY,
|
|
executionWorkspaceService,
|
|
metadataHasReopenPendingConsumption,
|
|
readExecutionWorkspaceLifecycleGeneration,
|
|
readMetadataReopenPendingConsumptionSince,
|
|
} from "../services/execution-workspaces.js";
|
|
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres reopen tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
const tempDirs: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-reopen-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(companyMemberships);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
await db.delete(authUsers);
|
|
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function makeExistingDir(): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), "paperclip-reopen-cwd-"));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
async function seedCompanyProject() {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `PAP-${companyId.slice(0, 8)}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Reopen project",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary",
|
|
sourceType: "local_path",
|
|
cwd: "/tmp/paperclip-reopen-project",
|
|
isPrimary: true,
|
|
});
|
|
return { companyId, projectId, projectWorkspaceId };
|
|
}
|
|
|
|
async function seedClosedWorkspace(input: {
|
|
companyId: string;
|
|
projectId: string;
|
|
projectWorkspaceId: string;
|
|
cwd: string;
|
|
status?: "archived" | "cleanup_failed" | "active";
|
|
generation?: number;
|
|
}) {
|
|
const workspaceId = randomUUID();
|
|
const closed = (input.status ?? "archived") !== "active";
|
|
await db.insert(executionWorkspaces).values({
|
|
id: workspaceId,
|
|
companyId: input.companyId,
|
|
projectId: input.projectId,
|
|
projectWorkspaceId: input.projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
// project_primary strategy so the rebuild only checks that the directory
|
|
// exists, with no git operation.
|
|
strategyType: "project_primary",
|
|
name: "reopen-workspace",
|
|
status: input.status ?? "archived",
|
|
providerType: "local_fs",
|
|
cwd: input.cwd,
|
|
closedAt: closed ? new Date() : null,
|
|
cleanupReason: closed ? "issue_terminal" : null,
|
|
cleanupEligibleAt: closed ? new Date() : null,
|
|
metadata: {
|
|
[EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: input.generation ?? 1,
|
|
},
|
|
});
|
|
return workspaceId;
|
|
}
|
|
|
|
// Seed a company and project whose primary project workspace has a null cwd.
|
|
// This models a managed_checkout project: the base is not a local folder, so
|
|
// the live managed checkout supplies the base path at rebuild time.
|
|
async function seedManagedCheckoutProject() {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `PAP-${companyId.slice(0, 8)}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Managed checkout project",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary",
|
|
sourceType: "managed_checkout",
|
|
cwd: null,
|
|
isPrimary: true,
|
|
});
|
|
return { companyId, projectId, projectWorkspaceId };
|
|
}
|
|
|
|
// Seed one closed isolated git_worktree row. The reaper already removed the
|
|
// worktree directory, so cwd points at a path that is not on disk.
|
|
async function seedClosedGitWorktreeWorkspace(input: {
|
|
companyId: string;
|
|
projectId: string;
|
|
projectWorkspaceId: string;
|
|
cwd: string;
|
|
repoUrl: string;
|
|
branchName: string;
|
|
}) {
|
|
const workspaceId = randomUUID();
|
|
await db.insert(executionWorkspaces).values({
|
|
id: workspaceId,
|
|
companyId: input.companyId,
|
|
projectId: input.projectId,
|
|
projectWorkspaceId: input.projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "reopen-git-worktree",
|
|
status: "archived",
|
|
providerType: "local_fs",
|
|
cwd: input.cwd,
|
|
repoUrl: input.repoUrl,
|
|
baseRef: null,
|
|
branchName: input.branchName,
|
|
closedAt: new Date(),
|
|
cleanupReason: "issue_terminal",
|
|
cleanupEligibleAt: new Date(),
|
|
metadata: {
|
|
[EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 1,
|
|
},
|
|
});
|
|
return workspaceId;
|
|
}
|
|
|
|
// Build a real git repository at the managed checkout path so
|
|
// ensureManagedProjectWorkspace adopts it without a network clone. The repo
|
|
// holds the branch that the archived worktree row references.
|
|
function initManagedGitRepo(dir: string, worktreeBranch: string) {
|
|
mkdirSync(dir, { recursive: true });
|
|
const git = (...args: string[]) =>
|
|
execFileSync("git", args, {
|
|
cwd: dir,
|
|
env: {
|
|
...process.env,
|
|
GIT_AUTHOR_NAME: "Test",
|
|
GIT_AUTHOR_EMAIL: "test@example.com",
|
|
GIT_COMMITTER_NAME: "Test",
|
|
GIT_COMMITTER_EMAIL: "test@example.com",
|
|
},
|
|
stdio: "ignore",
|
|
});
|
|
git("init");
|
|
writeFileSync(join(dir, "README.md"), "seed\n");
|
|
git("add", "README.md");
|
|
git("commit", "-m", "seed");
|
|
git("branch", worktreeBranch);
|
|
}
|
|
|
|
async function seedIssue(input: {
|
|
companyId: string;
|
|
projectId: string;
|
|
workspaceId: string;
|
|
issueNumber: number;
|
|
}) {
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId: input.companyId,
|
|
projectId: input.projectId,
|
|
identifier: `PAP-${input.issueNumber}`,
|
|
issueNumber: input.issueNumber,
|
|
title: "Resume me",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceId: input.workspaceId,
|
|
});
|
|
return issueId;
|
|
}
|
|
|
|
async function readWorkspace(id: string) {
|
|
return db
|
|
.select()
|
|
.from(executionWorkspaces)
|
|
.where(eq(executionWorkspaces.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
describe.each(["configured", "builtin"] as const)("reopen %s host provision containment", kind => {
|
|
it.each(["task", "agent", "organization", "source", "same-source", "trusted", "explicit", "projectless", "nondefault", "foreign-source-task", "retained-missing", "retained-false", "retained-null"] as const)("honors persisted %s policy before running repository scripts", async restriction => {
|
|
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
|
|
const directory = await makeExistingDir();
|
|
const base = join(directory, "source"), target = join(directory, "target"), marker = join(directory, "host-provision-ran");
|
|
initManagedGitRepo(base, "reopen-feature");
|
|
const command = `printf 'host execution\n' > '${marker}'`;
|
|
mkdirSync(join(base, "scripts"));
|
|
writeFileSync(join(base, "scripts", "provision-worktree.sh"), command + "\n");
|
|
const git = (...args: string[]) => execFileSync("git", args, { cwd: base, stdio: "ignore",
|
|
env: { ...process.env, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com" } });
|
|
git("add", "scripts"); git("commit", "-m", "Host provision fixture"); git("branch", "-f", "reopen-feature", "HEAD");
|
|
await db.update(projectWorkspaces).set({ cwd: base }).where(eq(projectWorkspaces.id, projectWorkspaceId));
|
|
const workspaceId = await seedClosedGitWorktreeWorkspace({ companyId, projectId, projectWorkspaceId,
|
|
cwd: target, repoUrl: pathToFileURL(base).href, branchName: "reopen-feature" });
|
|
if (kind === "configured") await db.update(executionWorkspaces).set({ metadata: {
|
|
[EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 1, config: { provisionCommand: command },
|
|
} }).where(eq(executionWorkspaces.id, workspaceId));
|
|
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4199 });
|
|
let organizationId: string | null = restriction === "projectless" ? null : projectId;
|
|
if (restriction === "organization") {
|
|
organizationId = randomUUID();
|
|
await db.insert(projects).values({ id: organizationId, companyId, name: "Organizational policy" });
|
|
}
|
|
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review",
|
|
trustBoundary: { mode: "low_trust_review", companyId, rootIssueId: issueId,
|
|
projectIds: [...new Set([projectId, ...(organizationId ? [organizationId] : [])])] } } };
|
|
const agentId = randomUUID();
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Reopen assignee",
|
|
permissions: restriction === "agent" ? policy : {} });
|
|
await db.update(issues).set({ projectId: organizationId, assigneeAgentId: agentId,
|
|
executionPolicy: restriction === "task" ? policy : null }).where(eq(issues.id, issueId));
|
|
if (["source", "same-source", "organization"].includes(restriction)) {
|
|
await db.update(projects).set({ executionWorkspacePolicy: policy })
|
|
.where(eq(projects.id, restriction === "organization" ? organizationId! : projectId));
|
|
}
|
|
if (restriction === "explicit" || restriction.startsWith("retained-")) {
|
|
await db.update(issues).set({ workspaceSelection: { version: 1, source: "explicit",
|
|
selection: { kind: "configured_source", projectWorkspaceId, mode: "managed_isolated" } } })
|
|
.where(eq(issues.id, issueId));
|
|
}
|
|
if (restriction === "nondefault") {
|
|
await db.update(projectWorkspaces).set({ isPrimary: false }).where(eq(projectWorkspaces.id, projectWorkspaceId));
|
|
await db.insert(projectWorkspaces).values({ companyId, projectId, name: "Earlier default",
|
|
sourceType: "local_path", cwd: base, isPrimary: true, createdAt: new Date(0) });
|
|
}
|
|
if (restriction === "foreign-source-task") {
|
|
const sourceIssueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4198 });
|
|
await db.update(executionWorkspaces).set({ sourceIssueId }).where(eq(executionWorkspaces.id, workspaceId));
|
|
}
|
|
if (restriction.startsWith("retained-")) {
|
|
const workspace = await readWorkspace(workspaceId);
|
|
await db.update(executionWorkspaces).set({ metadata: { ...workspace?.metadata,
|
|
_controllerProvisionCopyOnly: true } }).where(eq(executionWorkspaces.id, workspaceId));
|
|
await db.update(issues).set({ workspaceSelection: null }).where(eq(issues.id, issueId));
|
|
const service = executionWorkspaceService(db);
|
|
await service.update(workspaceId, { metadata: restriction === "retained-null" ? null
|
|
: restriction === "retained-false" ? { _controllerProvisionCopyOnly: false } : {} });
|
|
if (kind === "configured") await service.update(workspaceId, { metadata: { config: { provisionCommand: command } } });
|
|
expect((await readWorkspace(workspaceId))?.metadata).toMatchObject({ _controllerProvisionCopyOnly: true });
|
|
}
|
|
const result = await executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({
|
|
workspaceId, issue: { id: issueId, companyId, projectId: organizationId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
|
|
});
|
|
expect(result).toMatchObject({ ok: true, reopened: true });
|
|
await expect(readFile(join(target, "README.md"), "utf8")).resolves.toBe("seed\n");
|
|
if (restriction === "trusted") await expect(readFile(marker, "utf8")).resolves.toBe("host execution\n");
|
|
else await expect(readFile(marker, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await readWorkspace(workspaceId)).toMatchObject({ status: "active", projectId, projectWorkspaceId });
|
|
if (["explicit", "projectless", "nondefault", "foreign-source-task"].includes(restriction) || restriction.startsWith("retained-")) expect((await readWorkspace(workspaceId))?.metadata)
|
|
.toMatchObject({ _controllerProvisionCopyOnly: true });
|
|
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0])
|
|
.toMatchObject({ projectId: organizationId, executionWorkspaceId: workspaceId, assigneeAgentId: agentId });
|
|
});
|
|
});
|
|
|
|
it("reopens the archived row in place, keeps the issue link, and raises the generation", async () => {
|
|
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
|
|
const cwd = await makeExistingDir();
|
|
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd, generation: 3 });
|
|
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4100 });
|
|
|
|
const svc = executionWorkspaceService(db);
|
|
const events: string[] = [];
|
|
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.terminalWorkspaces, event => events.push(event));
|
|
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
|
|
workspaceId,
|
|
issue: { id: issueId, companyId, projectId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
|
|
}).finally(unsubscribe);
|
|
|
|
expect(events).toEqual(["intent", "settled"]);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.reopened).toBe(true);
|
|
|
|
const row = await readWorkspace(workspaceId);
|
|
expect(row?.status).toBe("active");
|
|
expect(row?.closedAt).toBeNull();
|
|
expect(row?.cleanupReason).toBeNull();
|
|
expect(row?.cleanupEligibleAt).toBeNull();
|
|
expect(readExecutionWorkspaceLifecycleGeneration(row?.metadata as Record<string, unknown> | null)).toBe(4);
|
|
// The reopen flags the row so the terminal reaper does not archive and
|
|
// destroy the rebuilt worktree before the caller consumes it.
|
|
expect(metadataHasReopenPendingConsumption(row?.metadata as Record<string, unknown> | null)).toBe(true);
|
|
|
|
// The reopen never changes the issue-to-workspace link.
|
|
const issueRow = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
|
|
expect(issueRow?.executionWorkspaceId).toBe(workspaceId);
|
|
});
|
|
|
|
it("reopens the persisted source after the task leaves its organizational project", async () => {
|
|
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
|
|
const cwd = await makeExistingDir();
|
|
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd });
|
|
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4105 });
|
|
await db.update(issues).set({ projectId: null }).where(eq(issues.id, issueId));
|
|
const result = await executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({
|
|
workspaceId,
|
|
issue: { id: issueId, companyId, projectId: null },
|
|
actor: { agentId: null, actorType: "user" },
|
|
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect((await readWorkspace(workspaceId))?.projectId).toBe(projectId);
|
|
});
|
|
|
|
it("keeps access for every issue that shares the reopened row", async () => {
|
|
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
|
|
const cwd = await makeExistingDir();
|
|
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd });
|
|
const firstIssueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4101 });
|
|
const secondIssueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4102 });
|
|
|
|
const svc = executionWorkspaceService(db);
|
|
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
|
|
workspaceId,
|
|
issue: { id: firstIssueId, companyId, projectId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
|
|
const rows = await db.select().from(issues);
|
|
for (const row of rows) {
|
|
expect(row.executionWorkspaceId).toBe(workspaceId);
|
|
}
|
|
const workspace = await readWorkspace(workspaceId);
|
|
expect(workspace?.status).toBe("active");
|
|
expect(firstIssueId).not.toBe(secondIssueId);
|
|
});
|
|
|
|
it("fails closed and keeps the row closed when the rebuild fails", async () => {
|
|
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
|
|
// A directory that does not exist. The project_primary rebuild returns null.
|
|
const missingDir = join(tmpdir(), `paperclip-reopen-missing-${randomUUID()}`);
|
|
const workspaceId = await seedClosedWorkspace({
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
cwd: missingDir,
|
|
generation: 2,
|
|
});
|
|
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4103 });
|
|
|
|
const svc = executionWorkspaceService(db);
|
|
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
|
|
workspaceId,
|
|
issue: { id: issueId, companyId, projectId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.code).toBe("rebuild_failed");
|
|
|
|
const row = await readWorkspace(workspaceId);
|
|
// The row stays closed and retryable. The generation still rises so a queued
|
|
// cleanup with the old generation does nothing.
|
|
expect(row?.status).toBe("archived");
|
|
expect(row?.cleanupReason).toBe(EXECUTION_WORKSPACE_REOPEN_FAILED_REASON);
|
|
expect(row?.cleanupEligibleAt).toBeNull();
|
|
expect(readExecutionWorkspaceLifecycleGeneration(row?.metadata as Record<string, unknown> | null)).toBe(3);
|
|
});
|
|
|
|
it("resolves the managed base checkout for a git_worktree row when the project workspace cwd is null", async () => {
|
|
const previousHome = process.env.PAPERCLIP_HOME;
|
|
const tempHome = await mkdtemp(join(tmpdir(), "paperclip-reopen-home-"));
|
|
tempDirs.push(tempHome);
|
|
process.env.PAPERCLIP_HOME = tempHome;
|
|
try {
|
|
const { companyId, projectId, projectWorkspaceId } = await seedManagedCheckoutProject();
|
|
const repoUrl = "https://example.test/acme/widget.git";
|
|
const branchName = "reopen-feature";
|
|
// Build the live managed checkout that the rebuild must spawn git in.
|
|
const managedDir = resolveManagedProjectWorkspaceDir({ companyId, projectId, repoName: "widget" });
|
|
initManagedGitRepo(managedDir, branchName);
|
|
|
|
// The archived worktree path. The reaper already removed it from disk.
|
|
const deletedWorktree = join(tempHome, "worktrees", "reopen-worktree");
|
|
const workspaceId = await seedClosedGitWorktreeWorkspace({
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
cwd: deletedWorktree,
|
|
repoUrl,
|
|
branchName,
|
|
});
|
|
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4305 });
|
|
|
|
const svc = executionWorkspaceService(db);
|
|
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
|
|
workspaceId,
|
|
issue: { id: issueId, companyId, projectId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.reopened).toBe(true);
|
|
|
|
const row = await readWorkspace(workspaceId);
|
|
expect(row?.status).toBe("active");
|
|
expect(row?.closedAt).toBeNull();
|
|
expect(row?.cleanupReason).toBeNull();
|
|
// The rebuild recreated the worktree at the archived path from the managed
|
|
// base checkout.
|
|
const worktreeStat = await stat(deletedWorktree).catch(() => null);
|
|
expect(worktreeStat?.isDirectory()).toBe(true);
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
|
else process.env.PAPERCLIP_HOME = previousHome;
|
|
}
|
|
});
|
|
|
|
it.each([false, true, "revoked_during_provision"] as const)("guards a private local managed reopen before Git and retains privacy after provisioning failure=%s", async scenario => {
|
|
const provisionFails = scenario === true;
|
|
const revokeDuringProvision = scenario === "revoked_during_provision";
|
|
const previousHome = process.env.PAPERCLIP_HOME;
|
|
const previousPrivacy = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
|
const home = await makeExistingDir();
|
|
process.env.PAPERCLIP_HOME = home;
|
|
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
|
try {
|
|
const { companyId, projectId, projectWorkspaceId } = await seedManagedCheckoutProject();
|
|
const readerId = randomUUID();
|
|
await db.insert(authUsers).values({ id: readerId, name: "Reader", email: `${readerId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: readerId, status: "active", membershipRole: "member" });
|
|
const authorizationActor = { type: "board" as const, source: "session" as const, userId: readerId, companyIds: [companyId], isInstanceAdmin: false };
|
|
const source = join(home, "private-source"), target = join(home, "worktree-copy");
|
|
initManagedGitRepo(source, "reopen-feature");
|
|
writeFileSync(join(source, "private.txt"), "PRIVATE_REOPEN_BYTES");
|
|
execFileSync("git", ["add", "private.txt"], { cwd: source });
|
|
execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "private"], { cwd: source });
|
|
execFileSync("git", ["branch", "-f", "reopen-feature", "HEAD"], { cwd: source });
|
|
const repoUrl = pathToFileURL(source).href;
|
|
await db.update(projectWorkspaces).set({ repoUrl }).where(eq(projectWorkspaces.id, projectWorkspaceId));
|
|
const privateIssueId = randomUUID();
|
|
await db.insert(issues).values({ id: privateIssueId, companyId, title: "Private source", visibility: "private", status: "todo", responsibleUserId: "another-owner" });
|
|
await db.insert(executionWorkspaces).values({ companyId, projectId: null, sourceIssueId: privateIssueId, name: "Private owner", mode: "shared_workspace", strategyType: "project_primary", cwd: source, providerType: "local_fs", status: "active" });
|
|
const workspaceId = await seedClosedGitWorktreeWorkspace({ companyId, projectId, projectWorkspaceId, cwd: target, repoUrl, branchName: "reopen-feature" });
|
|
const waitingProvision = `node -e 'const fs = require("node:fs"); fs.writeFileSync("dirty.txt", "DIRTY_REOPEN_BYTES"); fs.writeFileSync("provision-started", "ready"); const deadline = Date.now() + 15000; const timer = setInterval(() => { if (fs.existsSync("provision-continue")) { clearInterval(timer); process.exit(0); } if (Date.now() > deadline) process.exit(2); }, 20);'`;
|
|
if (provisionFails || revokeDuringProvision) await db.update(executionWorkspaces).set({ metadata: { [EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 1, config: { provisionCommand: provisionFails ? "exit 42" : waitingProvision } } }).where(eq(executionWorkspaces.id, workspaceId));
|
|
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4901 });
|
|
if (revokeDuringProvision) await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
|
|
const reopen = () => executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ workspaceId, issue: { id: issueId, companyId, projectId }, actor: { actorType: "user", agentId: null }, authorizationActor });
|
|
expect(await reopen()).toMatchObject({ ok: false, code: "rebuild_failed" });
|
|
await expect(stat(target)).rejects.toMatchObject({ code: "ENOENT" });
|
|
const managed = resolveManagedProjectWorkspaceDir({ companyId, projectId, repoName: "private-source" });
|
|
await expect(stat(managed)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await db.insert(issueAccessGrants).values({ issueId: privateIssueId, subjectType: "user", subjectId: readerId, source: "explicit" });
|
|
const attempt = revokeDuringProvision ? reopen() : null;
|
|
if (attempt) {
|
|
try {
|
|
await vi.waitFor(async () => expect(await readFile(join(target, "provision-started"), "utf8")).toBe("ready"), { timeout: 10_000 });
|
|
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
|
|
} finally {
|
|
await writeFile(join(target, "provision-continue"), "resume");
|
|
}
|
|
}
|
|
const results = attempt ? [await attempt] : provisionFails ? [await reopen()] : await Promise.all([reopen(), reopen()]);
|
|
expect(results.every(result => result.ok)).toBe(!provisionFails && !revokeDuringProvision);
|
|
if (!provisionFails && !revokeDuringProvision) expect(results.map(result => result.ok && result.reopened).sort()).toEqual([false, true]);
|
|
expect(await readFile(join(target, "private.txt"), "utf8")).toBe("PRIVATE_REOPEN_BYTES");
|
|
if (revokeDuringProvision) {
|
|
expect(results[0]).toMatchObject({ ok: false, code: "rebuild_failed" });
|
|
const reopened = await readWorkspace(workspaceId);
|
|
expect(reopened?.status).toBe("active");
|
|
expect(metadataHasReopenPendingConsumption(reopened?.metadata)).toBe(false);
|
|
expect(await readFile(join(target, "dirty.txt"), "utf8")).toBe("DIRTY_REOPEN_BYTES");
|
|
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, workspaceId));
|
|
expect(activity.map(row => row.action)).toContain("execution_workspace.reopen_unconsumed");
|
|
const archive = await executionWorkspaceService(db).archiveWorkspaceUnderLifecycleLock({ id: workspaceId, patch: {}, closedAt: new Date() });
|
|
expect(archive?.outcome).toBe("archived");
|
|
expect(await readFile(join(target, "dirty.txt"), "utf8")).toBe("DIRTY_REOPEN_BYTES");
|
|
} else {
|
|
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd: target, actor: authorizationActor })).resolves.toBeUndefined();
|
|
}
|
|
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, projectWorkspaceId));
|
|
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
|
|
await rm(source, { recursive: true, force: true });
|
|
for (const cwd of [managed, target]) await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd, actor: authorizationActor })).rejects.toMatchObject({ status: 403 });
|
|
expect(await readFile(join(target, "private.txt"), "utf8")).toBe("PRIVATE_REOPEN_BYTES");
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
|
if (previousPrivacy === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacy;
|
|
}
|
|
}, 30_000);
|
|
|
|
it("refuses to reopen a workspace in another company", async () => {
|
|
const first = await seedCompanyProject();
|
|
const second = await seedCompanyProject();
|
|
const cwd = await makeExistingDir();
|
|
const workspaceId = await seedClosedWorkspace({
|
|
companyId: first.companyId,
|
|
projectId: first.projectId,
|
|
projectWorkspaceId: first.projectWorkspaceId,
|
|
cwd,
|
|
});
|
|
|
|
const svc = executionWorkspaceService(db);
|
|
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
|
|
workspaceId,
|
|
// The issue belongs to a different company than the workspace.
|
|
issue: { id: randomUUID(), companyId: second.companyId, projectId: second.projectId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.code).toBe("not_reopenable");
|
|
|
|
const row = await readWorkspace(workspaceId);
|
|
expect(row?.status).toBe("archived");
|
|
});
|
|
|
|
it("reports success without a rebuild when the workspace is already active", async () => {
|
|
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
|
|
const cwd = await makeExistingDir();
|
|
const workspaceId = await seedClosedWorkspace({
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
cwd,
|
|
status: "active",
|
|
generation: 5,
|
|
});
|
|
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4104 });
|
|
|
|
const svc = executionWorkspaceService(db);
|
|
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
|
|
workspaceId,
|
|
issue: { id: issueId, companyId, projectId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.reopened).toBe(false);
|
|
|
|
const row = await readWorkspace(workspaceId);
|
|
// No reopen ran, so the generation is unchanged.
|
|
expect(readExecutionWorkspaceLifecycleGeneration(row?.metadata as Record<string, unknown> | null)).toBe(5);
|
|
});
|
|
|
|
it("runs the destroy under the fence when the generation matches, and skips it after a reopen", async () => {
|
|
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
|
|
const cwd = await makeExistingDir();
|
|
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd, generation: 7 });
|
|
|
|
const svc = executionWorkspaceService(db);
|
|
|
|
// The captured generation matches, so the destroy callback runs.
|
|
const destroyMatches = vi.fn(async () => "destroyed");
|
|
const matched = await svc.fenceClosedWorkspaceDestruction({
|
|
workspaceId,
|
|
capturedGeneration: 7,
|
|
destroy: destroyMatches,
|
|
});
|
|
expect(matched.skippedReopened).toBe(false);
|
|
expect(destroyMatches).toHaveBeenCalledTimes(1);
|
|
|
|
// Simulate a reopen: raise the generation and mark the row active.
|
|
await db
|
|
.update(executionWorkspaces)
|
|
.set({
|
|
status: "active",
|
|
closedAt: null,
|
|
cleanupReason: null,
|
|
cleanupEligibleAt: null,
|
|
metadata: { [EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 8 },
|
|
})
|
|
.where(eq(executionWorkspaces.id, workspaceId));
|
|
|
|
// A stale cleanup captured generation 7. The fence must skip the destroy.
|
|
const destroyStale = vi.fn(async () => "destroyed");
|
|
const skipped = await svc.fenceClosedWorkspaceDestruction({
|
|
workspaceId,
|
|
capturedGeneration: 7,
|
|
destroy: destroyStale,
|
|
});
|
|
expect(skipped.skippedReopened).toBe(true);
|
|
expect(destroyStale).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears the reopen-pending flag after an unconsumed reopen and stays idempotent", async () => {
|
|
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
|
|
const cwd = await makeExistingDir();
|
|
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd });
|
|
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4109 });
|
|
|
|
const svc = executionWorkspaceService(db);
|
|
// The reopen publishes the row as active and sets the reopen-pending flag.
|
|
const reopenResult = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
|
|
workspaceId,
|
|
issue: { id: issueId, companyId, projectId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
|
|
});
|
|
expect(reopenResult.ok).toBe(true);
|
|
if (!reopenResult.ok) throw new Error("reopen failed");
|
|
const reopenGeneration = reopenResult.generation;
|
|
const reopenedRow = await readWorkspace(workspaceId);
|
|
expect(metadataHasReopenPendingConsumption(reopenedRow?.metadata as Record<string, unknown> | null)).toBe(true);
|
|
// The reopen stamps the time it set the flag, so the reaper can age a
|
|
// stranded flag out of the way after the grace period.
|
|
expect(readMetadataReopenPendingConsumptionSince(reopenedRow?.metadata as Record<string, unknown> | null))
|
|
.toBeInstanceOf(Date);
|
|
|
|
// The caller never consumed the reopen, so clear the flag at its generation.
|
|
const cleared = await svc.clearReopenPendingConsumptionForUnconsumedReopen({
|
|
workspaceId,
|
|
issue: { id: issueId, companyId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
expectedGeneration: reopenGeneration,
|
|
});
|
|
expect(cleared.cleared).toBe(true);
|
|
|
|
const clearedRow = await readWorkspace(workspaceId);
|
|
// The flag is gone, so the terminal reaper can archive and reclaim the row.
|
|
expect(metadataHasReopenPendingConsumption(clearedRow?.metadata as Record<string, unknown> | null)).toBe(false);
|
|
// The clear removes the timestamp too, so no orphan key survives.
|
|
expect((clearedRow?.metadata as Record<string, unknown> | null)?.[EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY])
|
|
.toBeUndefined();
|
|
// The row stays active, so a retried resume can still reuse the rebuilt worktree.
|
|
expect(clearedRow?.status).toBe("active");
|
|
|
|
const events = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.entityId, workspaceId));
|
|
expect(events.some((event) => event.action === "execution_workspace.reopen_unconsumed")).toBe(true);
|
|
|
|
// A second call finds no flag and does nothing.
|
|
const second = await svc.clearReopenPendingConsumptionForUnconsumedReopen({
|
|
workspaceId,
|
|
issue: { id: issueId, companyId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
expectedGeneration: reopenGeneration,
|
|
});
|
|
expect(second.cleared).toBe(false);
|
|
const eventsAfter = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.entityId, workspaceId));
|
|
expect(eventsAfter.filter((event) => event.action === "execution_workspace.reopen_unconsumed").length).toBe(1);
|
|
});
|
|
|
|
it("does not clear a newer reopen's fence when a stale request presents an old generation", async () => {
|
|
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
|
|
const cwd = await makeExistingDir();
|
|
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd });
|
|
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4115 });
|
|
|
|
const svc = executionWorkspaceService(db);
|
|
const reopenResult = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
|
|
workspaceId,
|
|
issue: { id: issueId, companyId, projectId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
|
|
});
|
|
expect(reopenResult.ok).toBe(true);
|
|
if (!reopenResult.ok) throw new Error("reopen failed");
|
|
const staleGeneration = reopenResult.generation;
|
|
|
|
// Simulate a newer reopen that raised the generation and installed its own
|
|
// fence with a fresh timestamp. This models two overlapping reopen requests.
|
|
const newerGeneration = staleGeneration + 1;
|
|
await db
|
|
.update(executionWorkspaces)
|
|
.set({
|
|
metadata: {
|
|
[EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: newerGeneration,
|
|
[EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY]: true,
|
|
[EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY]: new Date().toISOString(),
|
|
},
|
|
})
|
|
.where(eq(executionWorkspaces.id, workspaceId));
|
|
|
|
// The stale request's response-end clear presents the old generation. It must
|
|
// not clear the newer reopen's live fence.
|
|
const staleClear = await svc.clearReopenPendingConsumptionForUnconsumedReopen({
|
|
workspaceId,
|
|
issue: { id: issueId, companyId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
expectedGeneration: staleGeneration,
|
|
});
|
|
expect(staleClear.cleared).toBe(false);
|
|
const afterStale = await readWorkspace(workspaceId);
|
|
expect(metadataHasReopenPendingConsumption(afterStale?.metadata as Record<string, unknown> | null)).toBe(true);
|
|
expect(readExecutionWorkspaceLifecycleGeneration(afterStale?.metadata as Record<string, unknown> | null))
|
|
.toBe(newerGeneration);
|
|
|
|
// The newer owner clears its own fence at the matching generation.
|
|
const ownerClear = await svc.clearReopenPendingConsumptionForUnconsumedReopen({
|
|
workspaceId,
|
|
issue: { id: issueId, companyId },
|
|
actor: { agentId: null, actorType: "user" },
|
|
expectedGeneration: newerGeneration,
|
|
});
|
|
expect(ownerClear.cleared).toBe(true);
|
|
const afterOwner = await readWorkspace(workspaceId);
|
|
expect(metadataHasReopenPendingConsumption(afterOwner?.metadata as Record<string, unknown> | null)).toBe(false);
|
|
});
|
|
});
|