FN-9048: clear disposed workspace state on unarchive
Prevent disposed workspace metadata from being revived when archived tasks return. - Reconcile missing workspace and singular worktree paths during archive restore. - Preserve surviving workspace entries and cover archive-to-restore recovery behavior. - Document the restore lifecycle and add a patch changeset. Files changed: .changeset/fn-9048-workspace-unarchive.md | 7 ++ docs/task-management.md | 2 +- .../archive-restore-workspace-worktrees.pg.test.ts | 70 +++++++++++++ .../core/src/task-store/archive-lifecycle-2.ts | 8 +- .../src/task-store/async/async-archive-lineage.ts | 43 ++++++++ .../src/__tests__/self-healing-workspace.test.ts | 111 ++++++++++++++++++++- 6 files changed, 236 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-9048 Fusion-Task-Lineage: fa9b9359-5fab-497f-87ce-0174d8069d99 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9048-workspace-unarchive.md
Normal file
7
.changeset/fn-9048-workspace-unarchive.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Prevent unarchived workspace tasks from retaining disposed worktree state.
|
||||||
|
category: fix
|
||||||
|
dev: restoreTaskFromArchive reconciles disposed workspace entries before reconcileWorkspacePartialLands runs.
|
||||||
@@ -108,7 +108,7 @@ This layer complements, rather than replaces, FN-4829 similarity detection, FN-4
|
|||||||
|
|
||||||
### Workspace worktree cleanup on archive
|
### Workspace worktree cleanup on archive
|
||||||
|
|
||||||
Archiving a workspace (multi-repository) task now synchronously removes every recorded per-sub-repository worktree, including archives initiated by `fn_task_archive` and CLI paths that do not construct an executor. Each path is protected by a per-repository cross-process reservation until backend removal and branch cleanup finish. If one removal fails, its reservation is quarantined and the next acquisition reconciles that orphan; successful sibling repositories are still released. `archiveTask(..., { cleanup: false })` intentionally retains worktrees, and the self-healing workspace sweep remains an idempotent backstop. See [Workspaces](./workspaces.md#archiving-and-cleanup) for the workspace operator lifecycle.
|
Archiving a workspace (multi-repository) task now synchronously removes every recorded per-sub-repository worktree, including archives initiated by `fn_task_archive` and CLI paths that do not construct an executor. Each path is protected by a per-repository cross-process reservation until backend removal and its `fusion/<task-id>` branch cleanup finish. If one removal fails, its reservation is quarantined and the next acquisition reconciles that orphan; successful sibling repositories are still released. On unarchive, Fusion reconciles the retained live-row metadata: it drops each per-repository entry whose exact worktree path is gone (including its `landedSha`) and clears a stale singular worktree path. A fully disposed workspace task consequently returns as a non-workspace card that must be re-executed rather than re-landed. `archiveTask(..., { cleanup: false })` intentionally retains worktrees, and the self-healing workspace sweep remains an idempotent backstop. See [Workspaces](./workspaces.md#archiving-and-cleanup) for the workspace operator lifecycle.
|
||||||
|
|
||||||
### Task-pinned orphan recovery
|
### Task-pinned orphan recovery
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createSharedPgTaskStoreTestHarness, pgDescribe, type SharedPgTaskStoreHarness } from "../../__test-utils__/pg-test-harness.js";
|
||||||
|
import { isWorkspaceTask } from "../../types.js";
|
||||||
|
|
||||||
|
pgDescribe("archive restore workspace worktrees", () => {
|
||||||
|
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_archive_restore_workspace" });
|
||||||
|
let root = "";
|
||||||
|
|
||||||
|
beforeAll(h.beforeAll);
|
||||||
|
afterAll(h.afterAll);
|
||||||
|
beforeEach(async () => { await h.beforeEach(); root = await mkdtemp(join(tmpdir(), "fusion-archive-restore-")); });
|
||||||
|
afterEach(async () => { await rm(root, { recursive: true, force: true }); await h.afterEach(); });
|
||||||
|
|
||||||
|
async function archivedTask(workspaceWorktrees: Record<string, unknown> | undefined, worktree?: string) {
|
||||||
|
const store = h.store();
|
||||||
|
const task = await store.createTaskWithReservedId(
|
||||||
|
{ description: "workspace archive restore", column: "in-review" },
|
||||||
|
{ taskId: `FN-RESTORE-${Date.now()}-${Math.random()}`, applyDefaultWorkflowSteps: false },
|
||||||
|
);
|
||||||
|
await store.updateTask(task.id, { workspaceWorktrees, worktree } as never);
|
||||||
|
await store.archiveTask(task.id, { cleanup: false });
|
||||||
|
return { store, id: task.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("drops removed repository entries and stale singular worktree on restore", async () => {
|
||||||
|
/*
|
||||||
|
FNXC:ArchiveRestore 2026-08-15-05:39:
|
||||||
|
Archive cleanup can remove every workspace path after the live row was soft-deleted. Unarchive
|
||||||
|
must not resurrect those entries or their landedSha values into a false partial-land candidate.
|
||||||
|
*/
|
||||||
|
const removedA = join(root, "repo-a");
|
||||||
|
const removedB = join(root, "repo-b");
|
||||||
|
const { store, id } = await archivedTask({
|
||||||
|
"repo-a": { worktreePath: removedA, branch: "fusion/a", landedSha: "landed-a" },
|
||||||
|
"repo-b": { worktreePath: removedB, branch: "fusion/b" },
|
||||||
|
}, removedA);
|
||||||
|
|
||||||
|
const restored = await store.unarchiveTask(id);
|
||||||
|
expect(restored.workspaceWorktrees).toBeUndefined();
|
||||||
|
expect(restored.worktree).toBeUndefined();
|
||||||
|
expect(isWorkspaceTask(restored)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing repository entries and landed state while removing disposed siblings", async () => {
|
||||||
|
const surviving = join(root, "repo-a");
|
||||||
|
await mkdir(surviving);
|
||||||
|
const { store, id } = await archivedTask({
|
||||||
|
"repo-a": { worktreePath: surviving, branch: "fusion/a", landedSha: "landed-a" },
|
||||||
|
"repo-b": { worktreePath: join(root, "repo-b"), branch: "fusion/b", landedSha: "landed-b" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const restored = await store.unarchiveTask(id);
|
||||||
|
expect(restored.workspaceWorktrees).toEqual({
|
||||||
|
"repo-a": { worktreePath: surviving, branch: "fusion/a", landedSha: "landed-a" },
|
||||||
|
});
|
||||||
|
expect(isWorkspaceTask(restored)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for absent and empty workspace maps", async () => {
|
||||||
|
for (const workspaceWorktrees of [undefined, {}]) {
|
||||||
|
const { store, id } = await archivedTask(workspaceWorktrees);
|
||||||
|
const restored = await store.unarchiveTask(id);
|
||||||
|
expect(restored.workspaceWorktrees).toBeUndefined();
|
||||||
|
expect(isWorkspaceTask(restored)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -741,7 +741,13 @@ export async function restoreFromArchiveImpl(store: TaskStore, entry: import("..
|
|||||||
noCommitsExpected: entry.noCommitsExpected,
|
noCommitsExpected: entry.noCommitsExpected,
|
||||||
modifiedFiles: entry.modifiedFiles,
|
modifiedFiles: entry.modifiedFiles,
|
||||||
declaredSymbols: entry.declaredSymbols,
|
declaredSymbols: entry.declaredSymbols,
|
||||||
// Intentionally NOT restoring: worktree, status, blockedBy, paused, executionStartBranch, baseCommitSha, error
|
/*
|
||||||
|
FNXC:ArchiveRestore 2026-08-15-05:39:
|
||||||
|
Cold archive entries intentionally omit per-repository worktree and landing state. Reconstructing
|
||||||
|
either `workspaceWorktrees` or `branch` would revive disposed paths and let the workspace
|
||||||
|
partial-land reconciler mistake an unarchived card for a recoverable landing.
|
||||||
|
*/
|
||||||
|
// Intentionally NOT restoring: worktree, workspaceWorktrees, branch, status, blockedBy, paused, executionStartBranch, baseCommitSha, error
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write task.json
|
// Write task.json
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
* interface (U4), not the underlying driver.
|
* interface (U4), not the underlying driver.
|
||||||
*/
|
*/
|
||||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
import { access } from "node:fs/promises";
|
||||||
import * as schema from "../../postgres/schema/index.js";
|
import * as schema from "../../postgres/schema/index.js";
|
||||||
import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js";
|
import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js";
|
||||||
import { ACTIVE_TASK_FILTER } from "./async-persistence.js";
|
import { ACTIVE_TASK_FILTER } from "./async-persistence.js";
|
||||||
@@ -304,6 +305,42 @@ export async function archiveParentTaskWithLineageGate(
|
|||||||
* @param taskRecord The task fields to re-insert (caller builds from the entry).
|
* @param taskRecord The task fields to re-insert (caller builds from the entry).
|
||||||
* @param context Serialization context for the task insert.
|
* @param context Serialization context for the task insert.
|
||||||
*/
|
*/
|
||||||
|
async function pathExists(path: unknown): Promise<boolean> {
|
||||||
|
if (typeof path !== "string" || !path) return false;
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:TaskStoreArchiveLineage 2026-08-15-05:39:
|
||||||
|
* Archive disposal removes each workspace worktree and its `fusion/<id>` branch but does not
|
||||||
|
* persist its in-memory map mutation. Restore therefore drops only entries whose exact recorded
|
||||||
|
* paths are gone, preventing reconcileWorkspacePartialLands FORK-A from parking the card failed.
|
||||||
|
*/
|
||||||
|
async function reconcileRestoredWorktreeState(
|
||||||
|
workspaceWorktrees: unknown,
|
||||||
|
worktree: unknown,
|
||||||
|
): Promise<{ workspaceWorktrees: Record<string, unknown> | null; worktree: string | null }> {
|
||||||
|
const entries = workspaceWorktrees && typeof workspaceWorktrees === "object" && !Array.isArray(workspaceWorktrees)
|
||||||
|
? Object.entries(workspaceWorktrees as Record<string, unknown>)
|
||||||
|
: [];
|
||||||
|
const surviving = await Promise.all(entries.map(async ([repoRel, value]) => {
|
||||||
|
const worktreePath = value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as { worktreePath?: unknown }).worktreePath
|
||||||
|
: undefined;
|
||||||
|
return typeof worktreePath === "string" && await pathExists(worktreePath) ? [repoRel, value] as const : undefined;
|
||||||
|
}));
|
||||||
|
const retained = surviving.filter((entry): entry is readonly [string, unknown] => entry !== undefined);
|
||||||
|
return {
|
||||||
|
workspaceWorktrees: retained.length > 0 ? Object.fromEntries(retained) : null,
|
||||||
|
worktree: typeof worktree === "string" && await pathExists(worktree) ? worktree : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function restoreTaskFromArchive(
|
export async function restoreTaskFromArchive(
|
||||||
layer: AsyncDataLayer,
|
layer: AsyncDataLayer,
|
||||||
entry: ArchivedTaskEntry,
|
entry: ArchivedTaskEntry,
|
||||||
@@ -322,6 +359,10 @@ export async function restoreTaskFromArchive(
|
|||||||
// OUTSIDE the txn.
|
// OUTSIDE the txn.
|
||||||
const existing = await readTaskRowInTransaction(tx, entry.id, { includeDeleted: true }, layer.projectId);
|
const existing = await readTaskRowInTransaction(tx, entry.id, { includeDeleted: true }, layer.projectId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
const reconciledWorktreeState = await reconcileRestoredWorktreeState(
|
||||||
|
existing.workspaceWorktrees,
|
||||||
|
existing.worktree,
|
||||||
|
);
|
||||||
// Row exists (was soft-deleted). Restore it: clear deleted_at, keep
|
// Row exists (was soft-deleted). Restore it: clear deleted_at, keep
|
||||||
// column as "archived" so the caller (unarchiveTaskImpl) can verify the
|
// column as "archived" so the caller (unarchiveTaskImpl) can verify the
|
||||||
// task is in the archived column and then moveTask it to the target
|
// task is in the archived column and then moveTask it to the target
|
||||||
@@ -331,6 +372,8 @@ export async function restoreTaskFromArchive(
|
|||||||
.update(schema.project.tasks)
|
.update(schema.project.tasks)
|
||||||
.set({
|
.set({
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
workspaceWorktrees: reconciledWorktreeState.workspaceWorktrees,
|
||||||
|
worktree: reconciledWorktreeState.worktree,
|
||||||
/*
|
/*
|
||||||
FNXC:TaskStoreArchiveLineage 2026-08-01-23:23 DELIBERATE-LITERAL — STATE MARKER:
|
FNXC:TaskStoreArchiveLineage 2026-08-01-23:23 DELIBERATE-LITERAL — STATE MARKER:
|
||||||
Restore exposes the durable row before the caller's validated move out of the archive state.
|
Restore exposes the durable row before the caller's validated move out of the archive state.
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ Surfaces (FN-5893):
|
|||||||
- FORK-A: branch-gone + landedSha-unset → parked failed; branch-gone + landedSha-set → skipped as landed.
|
- FORK-A: branch-gone + landedSha-unset → parked failed; branch-gone + landedSha-set → skipped as landed.
|
||||||
- regression: a single-repo (non-workspace) task → reconcilers behave identically.
|
- regression: a single-repo (non-workspace) task → reconcilers behave identically.
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { existsSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
import { registerArchiveWorkspaceWorktreeDisposer, type Settings, type Task, type TaskStore } from "@fusion/core";
|
||||||
|
import { createSharedPgTaskStoreTestHarness, pgDescribe, type SharedPgTaskStoreHarness } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||||
import { SelfHealingManager } from "../self-healing.js";
|
import { SelfHealingManager } from "../self-healing.js";
|
||||||
import { classifyBranchProbeError } from "../self-healing-git-evidence.js";
|
import { classifyBranchProbeError } from "../self-healing-git-evidence.js";
|
||||||
import { activeSessionRegistry, executingTaskLock } from "../agents/active-session-registry.js";
|
import { activeSessionRegistry, executingTaskLock } from "../agents/active-session-registry.js";
|
||||||
@@ -173,6 +174,91 @@ function workspaceTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Pa
|
|||||||
} as unknown as Task;
|
} as unknown as Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkspaceArchiveRestore 2026-08-15-05:55:
|
||||||
|
The archive-to-unarchive regression below uses the real PostgreSQL restore transaction and the
|
||||||
|
store-scoped archive disposal seam. Booting Executor would add unrelated session lifecycle work;
|
||||||
|
the seam is the production boundary that owns removing each sub-repo worktree and branch.
|
||||||
|
*/
|
||||||
|
const pgDescribeIfGit = hasGit ? pgDescribe : describe.skip;
|
||||||
|
|
||||||
|
pgDescribeIfGit("FN-9048 workspace archive restore reaches self-healing cleanly", () => {
|
||||||
|
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
|
||||||
|
prefix: "fusion_workspace_archive_restore_e2e",
|
||||||
|
});
|
||||||
|
let fx: WorkspaceFixture;
|
||||||
|
|
||||||
|
beforeAll(h.beforeAll);
|
||||||
|
beforeEach(async () => {
|
||||||
|
await h.beforeEach();
|
||||||
|
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
fx?.cleanup();
|
||||||
|
await h.afterEach();
|
||||||
|
});
|
||||||
|
afterAll(h.afterAll);
|
||||||
|
|
||||||
|
it("archives, disposes, restores, then skips FORK-A after its stale map is reconciled", async () => {
|
||||||
|
const store = h.store();
|
||||||
|
const id = "FN-9048-RESTORE-E2E";
|
||||||
|
const branch = `fusion/${id.toLowerCase()}`;
|
||||||
|
const workspaceWorktrees: NonNullable<Task["workspaceWorktrees"]> = {};
|
||||||
|
for (const repoRel of fx.repos) {
|
||||||
|
const worktreePath = path.join(fx.rootDir, ".worktrees", repoRel);
|
||||||
|
mkdirSync(path.dirname(worktreePath), { recursive: true });
|
||||||
|
fx.git(repoRel, `git worktree add -b ${branch} ${worktreePath} HEAD`);
|
||||||
|
workspaceWorktrees[repoRel] = { worktreePath, branch };
|
||||||
|
}
|
||||||
|
const task = await store.createTaskWithReservedId(
|
||||||
|
{ description: "archive workspace restore regression", column: "in-review" },
|
||||||
|
{ taskId: id, applyDefaultWorkflowSteps: false },
|
||||||
|
);
|
||||||
|
await store.updateTask(id, { workspaceWorktrees, branch: undefined } as never);
|
||||||
|
const stalePreArchive = (await store.getTask(id))!;
|
||||||
|
const unregister = registerArchiveWorkspaceWorktreeDisposer(store, async (_task, plan) => {
|
||||||
|
for (const entry of plan) {
|
||||||
|
fx.git(entry.repoRel, `git worktree remove --force ${entry.worktreePath}`);
|
||||||
|
fx.git(entry.repoRel, `git branch -D ${entry.branch}`);
|
||||||
|
}
|
||||||
|
return { removed: plan.map((entry) => entry.repoRel), failed: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.archiveTask(id);
|
||||||
|
for (const repoRel of fx.repos) {
|
||||||
|
expect(existsSync(workspaceWorktrees[repoRel]!.worktreePath)).toBe(false);
|
||||||
|
expect(fx.git(repoRel, `git show-ref --verify --quiet refs/heads/${branch}; echo $?`)).toBe("1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkspaceArchiveRestore 2026-08-15-05:55:
|
||||||
|
This captures the exact pre-fix resurrection shape: archive removed both branches, but the
|
||||||
|
soft-deleted row still has the old map and FORK-A proves it is unrecoverable.
|
||||||
|
*/
|
||||||
|
const staleStore = createStore([stalePreArchive]);
|
||||||
|
const staleManager = makeManager(staleStore, fx.rootDir);
|
||||||
|
await staleManager.reconcileWorkspacePartialLands();
|
||||||
|
expect(staleStore.updateTask).toHaveBeenCalledWith(id, expect.objectContaining({ status: "failed" }));
|
||||||
|
|
||||||
|
const restored = await store.unarchiveTask(id);
|
||||||
|
expect(restored.workspaceWorktrees).toBeUndefined();
|
||||||
|
const updateTask = vi.spyOn(store, "updateTask");
|
||||||
|
const recordRunAuditEvent = vi.spyOn(store, "recordRunAuditEvent");
|
||||||
|
const manager = makeManager(store, fx.rootDir);
|
||||||
|
|
||||||
|
expect(await manager.reconcileWorkspacePartialLands()).toBe(0);
|
||||||
|
expect(updateTask).not.toHaveBeenCalledWith(id, expect.objectContaining({ status: "failed" }));
|
||||||
|
expect(recordRunAuditEvent).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
mutationType: "task:reconcile-workspace-partial-land",
|
||||||
|
metadata: expect.objectContaining({ action: "park-failed" }),
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
unregister();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describeIfGit("workspace-aware self-healing (Phase D U1)", () => {
|
describeIfGit("workspace-aware self-healing (Phase D U1)", () => {
|
||||||
let fx: WorkspaceFixture;
|
let fx: WorkspaceFixture;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -354,6 +440,25 @@ describeIfGit("workspace-aware self-healing (Phase D U1)", () => {
|
|||||||
expect(store.enqueued).not.toContain(TASK_ID);
|
expect(store.enqueued).not.toContain(TASK_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips a restored fully-disposed workspace task after restore clears its map", async () => {
|
||||||
|
/*
|
||||||
|
FNXC:WorkspaceArchiveRestore 2026-08-15-05:39:
|
||||||
|
The preceding FORK-A case proves stale entries with deleted branches must fail loudly. Restore
|
||||||
|
clears that disposed map, so this same two-repository shape is no longer a workspace candidate.
|
||||||
|
*/
|
||||||
|
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||||
|
const restored = workspaceTask(undefined, { worktree: undefined });
|
||||||
|
const store = createStore([restored]);
|
||||||
|
const manager = makeManager(store, fx.rootDir);
|
||||||
|
|
||||||
|
expect(await manager.reconcileWorkspacePartialLands()).toBe(0);
|
||||||
|
expect(store.updateTask).not.toHaveBeenCalledWith(
|
||||||
|
TASK_ID,
|
||||||
|
expect.objectContaining({ status: "failed" }),
|
||||||
|
);
|
||||||
|
expect(store.emitted.some((event) => event.event === "task:reconcile-workspace-partial-land")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("classifies only a clean exit-1 branch probe error as absent", () => {
|
it("classifies only a clean exit-1 branch probe error as absent", () => {
|
||||||
expect(classifyBranchProbeError({ code: 1 })).toBe("absent");
|
expect(classifyBranchProbeError({ code: 1 })).toBe("absent");
|
||||||
for (const error of [
|
for (const error of [
|
||||||
|
|||||||
Reference in New Issue
Block a user