PAPA-430: workspace finalize gates + no-remote-git enforcement (#6969)

## Thinking Path

> - Paperclip orchestrates AI agents across isolated execution
workspaces; the local cwd is the only persistence boundary between runs.
> - Workspace lifecycle (worktree_prepare → execute →
workspace_finalize) and the wake/accept flow are what guarantee that
dependent issues see a consistent worktree.
> - PAPA-380 / PAPA-431 / PAPA-432 / PAPA-440 surfaced three holes in
that contract: silent env reuse across assignees, dependent wakes firing
before finalize, and `issue.interaction.accept` advancing before
finalize landed.
> - PAPA-441 / PAPA-442 then needed to document the "no remote git"
contract and prevent future adapter/runtime code from quietly
reintroducing `git push` as a backdoor sync.
> - This pull request lands those server fixes, the static
`check-no-git-push` enforcement, the AUTHORING.md cross-link, and the
Cody-review follow-ups on the PAPA-430 thread.
> - The benefit is that finalize is a real barrier — board accepts,
dependent wakes, and operator-set env all respect it — and adapter code
can't bypass it via raw `git push`.

## What Changed

- **server (PAPA-380, PAPA-431):** `execution-workspace-policy` refuses
silent env reuse when the assignee's resolved env disagrees with the
workspace it would inherit. The inheritance protection is now scoped to
the actual inheritance signal — explicit issue-level `environmentId` is
honored even when the agent's default env is `null`.
- **server (PAPA-432):** `heartbeat.ts` gates dependent wakes on
`listUnfinalizedExecutionWorkspaceIds`, and writes a
`workspace_finalize` row on the succeeded path. Write failures now
surface instead of being swallowed so dependents aren't silently
stranded behind a missing row.
- **server (PAPA-440):** `issue-thread-interactions.acceptInteraction`
adds a workspace_finalize precondition for `request_confirmation` (not
`suggest_tasks`). Accept returns 409 if finalize hasn't succeeded for
the latest workspace operation.
- **ci (PAPA-442):** new `scripts/check-no-git-push.mjs` static check
scans `packages/adapters/`, `packages/adapter-utils/`, `server/src/`,
and `cli/src/` for any `git push` invocation (string or args-array).
Wired into the `policy` PR job and `test:release-registry`. Operators
can opt in per-call with `// paperclip:allow-git-push: <reason>`.
Release scripts are out of scope by design.
- **docs (PAPA-441):** `AUTHORING.md` documents the no-remote-git
contract and cross-links the static check so adapter authors learn the
rule and the enforcement together.
- **review follow-up (PAPA-430, Cody):** three fixes — env resolver bug,
accept-gate scope (request_confirmation only), and finalize record write
on the succeeded path.

## Verification

- `pnpm exec vitest run
server/src/__tests__/execution-workspace-policy.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts` → 33/33
pass
- `node scripts/check-no-git-push.test.mjs` → check covers string form,
args-array form, comment exclusions, and per-line allow-comment.
- Manual: server compiles; the policy job runs the check in <1s before
heavier jobs.

## Risks

- **Behavioral shift in accept:** boards accepting
`request_confirmation` while finalize is in-flight now get 409s. This is
intentional — they can retry — but it changes timing on a hot path.
`suggest_tasks` is unaffected.
- **Workspace policy:** the env-reuse refusal is a new error path.
Issues that previously silently reused an env from a different-assignee
workspace will now fail-loud; the resolver still honors explicit
issue-level `executionWorkspaceSettings.environmentId`.
- **CI rule:** any future legitimate `git push` in scoped dirs must be
marked with the allow-comment, which is the intended ergonomic.

## Model Used

- Claude Opus 4.7 (`claude-opus-4-7`, extended thinking), via Claude
Code in the Paperclip executor adapter.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (N/A — server/CI/docs only)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Closes related issues: PAPA-430, PAPA-380, PAPA-431, PAPA-432, PAPA-440,
PAPA-441, PAPA-442

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-05-29 08:25:29 -07:00 committed by GitHub
parent 524e18b060
commit 1f70fd9a22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 21133 additions and 95 deletions

View file

@ -45,6 +45,12 @@ jobs:
- name: Validate Dockerfile deps stage
run: node ./scripts/check-docker-deps-stage.mjs
- name: Reject git push in adapter/runtime code
run: node ./scripts/check-no-git-push.mjs
- name: Test no-git-push check
run: node --test ./scripts/check-no-git-push.test.mjs
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check

View file

@ -249,6 +249,23 @@ Make Paperclip skills discoverable to your agent runtime without writing to the
3. **Acceptable: env var** — point a skills path env var at the repo's `skills/` directory
4. **Last resort: prompt injection** — include skill content in the prompt template
## Cross-run workspace persistence (no-remote-git contract)
The local execution-workspace cwd is the **only** persistence boundary across runs. No adapter may depend on a git remote for cross-run state.
The supported round-trip:
- **Per-run, on the remote side.** `prepareWorkspaceForSshExecution` (in `packages/adapter-utils/src/ssh.ts`) git-bundles the local worktree and ships it to the run's remote dir. No `git remote` is set anywhere; the bundle is the transport.
- **End-of-run, in the adapter's `finally` block.** The adapter invokes `restoreRemoteWorkspace` (e.g. claude-local's `execute.ts`), which calls `restoreWorkspaceFromSshExecution``exportGitWorkspaceFromSsh``integrateImportedGitHead`. Remote commits made during the run land back in the local Mac worktree with no `git push` and no remote configured.
The invariant adapters must preserve:
- **Never `git push`** from adapter or runtime code. Operator-supplied configuration may opt in, but the default contract is no remote operations.
- **Never assume a remote exists.** The local cwd is the source of truth between runs.
- **Surface restore failures.** A failed sync-back must propagate as a run-level error, not a silent warning. The heartbeat records a `workspace_finalize` row (`succeeded`/`failed`) around `adapter.execute` so dependent issues do not wake on a stale worktree.
The invariant is pinned by the "no-remote-git contract" case in `packages/adapter-utils/src/ssh-fixture.test.ts`: it asserts `git remote` is empty before and after the round-trip and that a remote-only commit still lands locally via restore alone.
## Security
- Treat agent output as untrusted (parse defensively, never execute)

View file

@ -64,6 +64,17 @@ Heartbeat still resolves a workspace for the run, but that is about code locatio
4. Heartbeat passes the resolved code workspace to the agent run.
5. Workspace runtime services remain manual UI-managed controls rather than automatic heartbeat-managed services.
## Cross-run persistence (no-remote-git contract)
Code state moves between runs through the local execution-workspace cwd alone — not through a git remote.
- Each run's prepare step bundles the local worktree to the run's remote dir over ssh, with no `git remote` configured.
- The adapter's restore step at the end of the run writes any new remote commits back into the local worktree directly.
- Adapters must never `git push` from runtime code, and must never assume a remote exists.
- A failed restore is a run-level error and records `workspace_finalize=failed` on the execution workspace, which gates dependent issue wakes until the next successful finalize.
The invariant is enforced by the "no-remote-git contract" case in `packages/adapter-utils/src/ssh-fixture.test.ts`, which asserts a remote-only commit reaches the local worktree with no remote configured at any point.
## Current implementation guarantees
With the current implementation:

View file

@ -35,12 +35,14 @@
"release:rollback": "./scripts/rollback-latest.sh",
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
"check:no-git-push": "node scripts/check-no-git-push.mjs",
"test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs",
"docs:dev": "cd docs && npx mintlify dev",
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs",
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
"test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",

View file

@ -0,0 +1,37 @@
# @paperclipai/adapter-utils
Shared utilities for Paperclip adapters: process spawning, environment
injection, sandbox/SSH transport, workspace sync, and the round-trip helpers
that move code between the local execution-workspace cwd and wherever the
agent actually runs.
For the adapter-author guide see
[`docs/adapters/creating-an-adapter.md`](../../docs/adapters/creating-an-adapter.md)
and the in-repo notes at [`packages/adapters/AUTHORING.md`](../adapters/AUTHORING.md).
## No-remote-git contract
The local execution-workspace cwd is the only persistence boundary across
runs. No adapter may depend on a git remote for cross-run state.
Adapters that run the agent on a different host should use the SSH round-trip
helpers in [`src/ssh.ts`](./src/ssh.ts):
- `prepareWorkspaceForSshExecution({ spec, localDir, remoteDir })` — bundles
the local cwd (tracked files, dirty edits, untracked additions, and the git
history needed to reconstruct it) to `remoteDir` before the run starts. Runs
with no `git remote` configured.
- `restoreWorkspaceFromSshExecution({ spec, localDir, remoteDir, ... })`
syncs the remote cwd back into `localDir` after the run, including any new
commits the agent created. Also runs with no `git remote` configured.
`prepareRemoteManagedRuntime` in
[`src/remote-managed-runtime.ts`](./src/remote-managed-runtime.ts) wraps both
calls for adapters that want a per-run remote workspace and an automatic
`restoreWorkspace()` finally hook.
The invariant is pinned by the `no-remote-git contract` case in
[`src/ssh-fixture.test.ts`](./src/ssh-fixture.test.ts), which asserts that a
remote-only commit propagates to the local worktree through the
prepare → restore round-trip with no git remote configured at any point. Do
not regress that test.

View file

@ -451,6 +451,68 @@ describe("ssh env-lab fixture", () => {
await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("propagates remote commits to the local worktree with no git remote configured (no-remote-git contract)", async () => {
// Locks in the architectural contract documented in
// packages/adapter-utils/README.md and packages/adapters/AUTHORING.md:
// the local execution-workspace cwd is the only persistence boundary
// across runs. No adapter may depend on a git remote for cross-run state.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
// Assert there is no git remote configured before we begin, and verify
// that no point in the round-trip introduces one. `git remote` returns an
// empty string when no remotes exist (and exit code 0).
expect(await git(localRepo, ["remote"])).toBe("");
const started = await startSshEnvLabFixtureOrSkip(
statePath,
"no-remote-git contract test",
);
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const prepared = await prepareRemoteManagedRuntime({
spec,
runId: "run-no-remote",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
// Remote commit lands a deliverable that must show up locally via
// sync-back alone — no `git push`, no fetch from any origin.
await runSshCommand(
config,
`cd ${JSON.stringify(prepared.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "deliverable\\n" > tracked.txt && git add tracked.txt && git commit -m "remote-only commit" >/dev/null`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await prepared.restoreWorkspace();
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe(
"remote-only commit",
);
expect(await readFile(path.join(localRepo, "tracked.txt"), "utf8")).toBe(
"deliverable\n",
);
// Final assertion: still no git remote — restore did not silently add one.
expect(await git(localRepo, ["remote"])).toBe("");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("merges concurrent remote commits through the managed runtime restore path", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);

View file

@ -0,0 +1,58 @@
# Adapter Authoring Notes
In-repo notes for adapter authors. The user-facing guide lives at
[`docs/adapters/creating-an-adapter.md`](../../docs/adapters/creating-an-adapter.md);
this file holds invariants that are easy to violate from inside the adapter
package itself.
## No-remote-git contract (cross-run persistence)
The local execution-workspace cwd is the only persistence boundary across
runs. No adapter may depend on a git remote for cross-run state.
Why: Paperclip resolves a local execution workspace (a worktree) for each
heartbeat. Code state is carried forward by syncing that local cwd to wherever
the agent actually runs — over ssh, into a sandbox, into a managed runtime —
and then syncing changes back when the run finishes. Treating a `git remote`
as the source of truth (`git push` from inside the agent, fetch on the next
wake) breaks dependent issues that are gated on the local worktree being
caught up, and breaks isolated execution workspaces that have no remote
configured at all.
How to apply:
- Never `git push` from adapter runtime code. Never assume the local worktree
has any `git remote` configured. If you need data from the previous run,
read it from the local cwd Paperclip handed you.
- If your adapter runs the agent on a different host (ssh, sandbox, remote
container), use the round-trip helpers in `@paperclipai/adapter-utils`:
[`prepareWorkspaceForSshExecution`](../adapter-utils/src/ssh.ts) bundles the
local cwd to the remote dir before the run, and
[`restoreWorkspaceFromSshExecution`](../adapter-utils/src/ssh.ts) syncs
remote-side changes (including new git commits) back into the local cwd
after the run. Both run with no `git remote` configured.
- If your adapter runs the agent locally, you can read and write the cwd
directly — same invariant applies: changes that future runs need must live
in the local cwd by the time `execute()` returns.
- A failed sync-back is a run-level error. The heartbeat records
`workspace_finalize=failed` on the execution workspace, which gates
dependent issue wakes until the next successful finalize. Do not swallow
restore errors.
The invariant is pinned by the `no-remote-git contract` case in
[`packages/adapter-utils/src/ssh-fixture.test.ts`](../adapter-utils/src/ssh-fixture.test.ts),
which asserts that a remote-only commit propagates to the local worktree
through `prepareWorkspaceForSshExecution``restoreWorkspaceFromSshExecution`
with no git remote configured at any point.
A static check enforces the rule before runtime ever sees it:
[`scripts/check-no-git-push.mjs`](../../scripts/check-no-git-push.mjs) scans
adapter and runtime source (`packages/adapters/`, `packages/adapter-utils/`,
`server/src/`, `cli/src/`) and fails the `policy` CI job if any unapproved
`git push` invocation is added. If you are building an operator-configured
path that legitimately must push, add a
`// paperclip:allow-git-push: <reason>` comment on the line (or the line
above) so the opt-in shows up in code review.
For the architecture-level write-up of cross-run persistence, see
[`docs/guides/board-operator/execution-workspaces-and-runtime-services.md`](../../docs/guides/board-operator/execution-workspaces-and-runtime-services.md#cross-run-persistence-no-remote-git-contract).

View file

@ -70,3 +70,16 @@ Structured gateway event logs use:
- `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames
UI/CLI parsers consume these lines to render transcript updates.
## No-remote-git contract
Like every Paperclip adapter, this one must treat the local execution-workspace
cwd as the only persistence boundary across runs — no `git push` from runtime
code, no assuming a `git remote` exists. The gateway transport here doesn't
touch the workspace directly, but if you extend the adapter to ship code to
the OpenClaw side, use the round-trip helpers in `@paperclipai/adapter-utils`
(`prepareWorkspaceForSshExecution``restoreWorkspaceFromSshExecution`)
rather than reaching for a git remote. See
[`packages/adapters/AUTHORING.md`](../AUTHORING.md#no-remote-git-contract-cross-run-persistence)
for the full contract and the pinning test at
[`packages/adapter-utils/src/ssh-fixture.test.ts`](../../adapter-utils/src/ssh-fixture.test.ts).

View file

@ -0,0 +1,6 @@
ALTER TABLE "execution_workspaces" DROP CONSTRAINT "execution_workspaces_company_id_companies_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace_operations" DROP CONSTRAINT "workspace_operations_company_id_companies_id_fk";
--> statement-breakpoint
ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load diff

View file

@ -652,6 +652,13 @@
"when": 1779999768200,
"tag": "0092_mighty_puma",
"breakpoints": true
},
{
"idx": 93,
"version": "7",
"when": 1780040470886,
"tag": "0093_giant_green_goblin",
"breakpoints": true
}
]
}

View file

@ -16,7 +16,7 @@ export const executionWorkspaces = pgTable(
"execution_workspaces",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
sourceIssueId: uuid("source_issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }),

View file

@ -17,7 +17,7 @@ export const workspaceOperations = pgTable(
"workspace_operations",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
executionWorkspaceId: uuid("execution_workspace_id").references(() => executionWorkspaces.id, {
onDelete: "set null",
}),

View file

@ -2,7 +2,8 @@ export type WorkspaceOperationPhase =
| "worktree_prepare"
| "workspace_provision"
| "workspace_teardown"
| "worktree_cleanup";
| "worktree_cleanup"
| "workspace_finalize";
export type WorkspaceOperationStatus = "running" | "succeeded" | "failed" | "skipped";

View file

@ -0,0 +1,196 @@
#!/usr/bin/env node
/**
* check-no-git-push.mjs
*
* Static check that rejects `git push` (and equivalent remote-mutating git
* invocations) inside adapter/runtime source code.
*
* Adapter and runtime code may never push to a git remote: the local
* execution-workspace cwd is the only persistence boundary between runs
* (see packages/adapters/AUTHORING.md and PAPA-432). Release tooling and
* developer scripts that legitimately push are out of scope because they
* live outside the directories scanned here.
*
* Opt-in mechanism: a line containing `paperclip:allow-git-push` (typically
* inside a `// paperclip:allow-git-push: <reason>` comment on the line itself
* or the line immediately above) suppresses the match. This is reserved for
* operator-configured paths that legitimately push and must be reviewed.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const DEFAULT_SCAN_ROOTS = [
"packages/adapters",
"packages/adapter-utils",
"server/src",
"cli/src",
];
const SCANNABLE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs"]);
const SKIP_DIRECTORY_NAMES = new Set([
"node_modules",
"dist",
"build",
".turbo",
".next",
"coverage",
]);
const SKIP_FILENAME_SUFFIXES = [".d.ts"];
// Matches actual git push invocations in either:
// `git push ...` (shell command string)
// ["git", "push", ...] (args-array form for execSync)
// execFile("git", ["push", ...]) / spawn("git", ["push", ...])
export const GIT_PUSH_PATTERNS = [
/\bgit[\s_-]+push\b/i,
/["'`]git["'`]\s*,\s*\[?\s*["'`]push["'`]/i,
];
// Kept for backwards-compatibility with existing tests/importers.
export const GIT_PUSH_PATTERN = GIT_PUSH_PATTERNS[0];
export const ALLOW_MARKER = "paperclip:allow-git-push";
function lineMatchesGitPush(line) {
return GIT_PUSH_PATTERNS.some((pattern) => pattern.test(line));
}
function stripLineComment(line) {
// Strip everything from the first `//` that is not inside a string literal.
// This is a lightweight heuristic: we only need to remove obvious doc-style
// mentions of "git push" so they do not trip the check. The check still
// flags any match that survives comment stripping.
let inSingle = false;
let inDouble = false;
let inBacktick = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
// A character is escaped only if it's preceded by an odd number of
// backslashes; e.g. `"foo\\"` ends a string because the trailing `\\`
// is a single escaped backslash, leaving the closing `"` unescaped.
let backslashes = 0;
for (let scan = index - 1; scan >= 0 && line[scan] === "\\"; scan -= 1) {
backslashes += 1;
}
const isEscaped = backslashes % 2 === 1;
if (!inDouble && !inBacktick && char === "'" && !isEscaped) inSingle = !inSingle;
else if (!inSingle && !inBacktick && char === '"' && !isEscaped) inDouble = !inDouble;
else if (!inSingle && !inDouble && char === "`" && !isEscaped) inBacktick = !inBacktick;
else if (!inSingle && !inDouble && !inBacktick && char === "/" && line[index + 1] === "/") {
return line.slice(0, index);
}
}
return line;
}
export function findGitPushOffenses(text) {
const lines = text.split("\n");
const offenses = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const stripped = stripLineComment(line);
if (!lineMatchesGitPush(stripped)) continue;
const previousLine = index > 0 ? lines[index - 1] : "";
const isAllowed = line.includes(ALLOW_MARKER) || previousLine.includes(ALLOW_MARKER);
if (isAllowed) continue;
offenses.push({ lineNumber: index + 1, line: line.trimEnd() });
}
return offenses;
}
function shouldScanFile(relativePath) {
if (SKIP_FILENAME_SUFFIXES.some((suffix) => relativePath.endsWith(suffix))) return false;
const extension = path.extname(relativePath);
return SCANNABLE_EXTENSIONS.has(extension);
}
export function collectScannableFiles(absoluteRoot, repoRoot) {
const results = [];
let stats;
try {
stats = statSync(absoluteRoot);
} catch {
return results;
}
if (!stats.isDirectory()) return results;
const stack = [absoluteRoot];
while (stack.length > 0) {
const current = stack.pop();
let entries;
try {
entries = readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRECTORY_NAMES.has(entry.name)) continue;
stack.push(path.join(current, entry.name));
continue;
}
const absolute = path.join(current, entry.name);
const relative = path.relative(repoRoot, absolute).split(path.sep).join("/");
if (shouldScanFile(relative)) results.push({ absolute, relative });
}
}
return results;
}
export function runCheck({ repoRoot, scanRoots = DEFAULT_SCAN_ROOTS, log = console.log, error = console.error } = {}) {
const allOffenses = [];
for (const scanRoot of scanRoots) {
const absoluteRoot = path.resolve(repoRoot, scanRoot);
const files = collectScannableFiles(absoluteRoot, repoRoot);
for (const file of files) {
let text;
try {
text = readFileSync(file.absolute, "utf8");
} catch {
continue;
}
const offenses = findGitPushOffenses(text);
for (const offense of offenses) {
allOffenses.push({ relative: file.relative, ...offense });
}
}
}
if (allOffenses.length > 0) {
error("ERROR: `git push` (or equivalent remote-mutating git command) found in adapter/runtime code:\n");
for (const offense of allOffenses) {
error(` ${offense.relative}:${offense.lineNumber}: ${offense.line}`);
}
error(
"\nAdapter and runtime code must not push to a git remote. The local execution-workspace cwd is the only persistence boundary between runs (see packages/adapters/AUTHORING.md and PAPA-432).",
);
error(
`If the operator has explicitly configured a path that must push, add a \`${ALLOW_MARKER}: <reason>\` comment on the matching line or the line immediately above to opt in.`,
);
return 1;
}
log(` ✓ No unapproved \`git push\` invocations found in adapter/runtime code.`);
return 0;
}
function isMainModule() {
return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
}
if (isMainModule()) {
const repoRoot = process.cwd();
process.exit(runCheck({ repoRoot }));
}

View file

@ -0,0 +1,170 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
ALLOW_MARKER,
GIT_PUSH_PATTERN,
collectScannableFiles,
findGitPushOffenses,
runCheck,
} from "./check-no-git-push.mjs";
test("regex matches common git push forms", () => {
assert.ok(GIT_PUSH_PATTERN.test("git push"));
assert.ok(GIT_PUSH_PATTERN.test("GIT PUSH"));
assert.ok(GIT_PUSH_PATTERN.test("git push origin master"));
assert.ok(GIT_PUSH_PATTERN.test("git-push"));
assert.ok(GIT_PUSH_PATTERN.test("git_push"));
});
test("regex ignores unrelated `push` usages", () => {
assert.ok(!GIT_PUSH_PATTERN.test("args.push('git')"));
assert.ok(!GIT_PUSH_PATTERN.test("notes.push('git remote')"));
assert.ok(!GIT_PUSH_PATTERN.test("pushed"));
assert.ok(!GIT_PUSH_PATTERN.test("git fetch"));
});
test("findGitPushOffenses flags a bare invocation in a string", () => {
const text = `await exec("git push origin master");\n`;
const offenses = findGitPushOffenses(text);
assert.equal(offenses.length, 1);
assert.equal(offenses[0].lineNumber, 1);
});
test("findGitPushOffenses ignores mentions inside `//` comments", () => {
const text = `// sync-back alone — no \`git push\`, no fetch from any origin.\nconst x = 1;\n`;
assert.deepEqual(findGitPushOffenses(text), []);
});
test("findGitPushOffenses allows opt-in marker on the same line", () => {
const text = `await exec("git push origin master"); // ${ALLOW_MARKER}: operator-configured release mirror\n`;
assert.deepEqual(findGitPushOffenses(text), []);
});
test("findGitPushOffenses allows opt-in marker on the line above", () => {
const text = `// ${ALLOW_MARKER}: operator-configured release mirror\nawait exec("git push origin master");\n`;
assert.deepEqual(findGitPushOffenses(text), []);
});
test("findGitPushOffenses flags string-literal push even when text is split across mixed quotes", () => {
const text = "const cmd = `git push --tags`;\n";
const offenses = findGitPushOffenses(text);
assert.equal(offenses.length, 1);
});
test("findGitPushOffenses flags args-array form passed to spawn/execFile", () => {
const cases = [
`spawn("git", ["push", "origin", "main"]);\n`,
`execFile('git', ['push', '--tags']);\n`,
"execFile(`git`, [`push`, `--mirror`]);\n",
];
for (const text of cases) {
const offenses = findGitPushOffenses(text);
assert.equal(offenses.length, 1, `expected match for ${text}`);
}
});
test("findGitPushOffenses ignores `git push` in a comment after a string ending with a literal backslash", () => {
// The closing `"` after `\\` should end the string (even literal count of
// backslashes leaves the quote unescaped), so the `// git push` that
// follows is comment text and must be stripped.
const text = 'const path = "C:\\\\"; // git push origin master\nconst y = 2;\n';
assert.deepEqual(findGitPushOffenses(text), []);
});
test("findGitPushOffenses does not flag args-array form when allow marker is present", () => {
const text = `// ${ALLOW_MARKER}: release tooling adapter\nspawn("git", ["push", "origin", "main"]);\n`;
assert.deepEqual(findGitPushOffenses(text), []);
});
test("runCheck passes when scoped tree has no offenses", () => {
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "no-git-push-pass-"));
try {
mkdirSync(path.join(tmpRoot, "packages/adapters/sample/src"), { recursive: true });
writeFileSync(
path.join(tmpRoot, "packages/adapters/sample/src/index.ts"),
"export const ok = 1;\n",
);
const logs = [];
const errors = [];
const code = runCheck({
repoRoot: tmpRoot,
scanRoots: ["packages/adapters"],
log: (msg) => logs.push(msg),
error: (msg) => errors.push(msg),
});
assert.equal(code, 0);
assert.equal(errors.length, 0);
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
test("runCheck fails when scoped tree contains an unapproved git push", () => {
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "no-git-push-fail-"));
try {
mkdirSync(path.join(tmpRoot, "packages/adapters/sample/src"), { recursive: true });
writeFileSync(
path.join(tmpRoot, "packages/adapters/sample/src/index.ts"),
"import { execSync } from 'node:child_process';\nexecSync('git push origin main');\n",
);
const logs = [];
const errors = [];
const code = runCheck({
repoRoot: tmpRoot,
scanRoots: ["packages/adapters"],
log: (msg) => logs.push(msg),
error: (msg) => errors.push(msg),
});
assert.equal(code, 1);
assert.ok(errors.some((line) => line.includes("packages/adapters/sample/src/index.ts:2")));
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
test("runCheck ignores opt-in marker outside the scoped tree", () => {
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "no-git-push-scope-"));
try {
mkdirSync(path.join(tmpRoot, "scripts"), { recursive: true });
writeFileSync(
path.join(tmpRoot, "scripts/release.mjs"),
"execSync('git push origin v1.2.3');\n",
);
const code = runCheck({
repoRoot: tmpRoot,
scanRoots: ["packages/adapters", "server/src"],
log: () => {},
error: () => {},
});
assert.equal(code, 0);
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
test("collectScannableFiles skips node_modules, dist, and .d.ts", () => {
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "no-git-push-collect-"));
try {
const adaptersRoot = path.join(tmpRoot, "packages/adapters/sample");
mkdirSync(path.join(adaptersRoot, "src"), { recursive: true });
mkdirSync(path.join(adaptersRoot, "dist"), { recursive: true });
mkdirSync(path.join(adaptersRoot, "node_modules/pkg"), { recursive: true });
writeFileSync(path.join(adaptersRoot, "src/index.ts"), "");
writeFileSync(path.join(adaptersRoot, "src/types.d.ts"), "");
writeFileSync(path.join(adaptersRoot, "dist/index.js"), "");
writeFileSync(path.join(adaptersRoot, "node_modules/pkg/index.js"), "");
const files = collectScannableFiles(
path.join(tmpRoot, "packages/adapters"),
tmpRoot,
);
const relatives = files.map((entry) => entry.relative).sort();
assert.deepEqual(relatives, ["packages/adapters/sample/src/index.ts"]);
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});

View file

@ -148,16 +148,117 @@ describe("execution workspace policy helpers", () => {
});
});
it("prefers persisted environment selection over issue and project defaults", () => {
it("reuses persisted workspace environment when it agrees with the assignee's identity", () => {
expect(
resolveExecutionWorkspaceEnvironmentId({
projectPolicy: { enabled: true, environmentId: "agent-env" },
issueSettings: { environmentId: "agent-env" },
workspaceConfig: { environmentId: "agent-env" },
agentDefaultEnvironmentId: "agent-env",
defaultEnvironmentId: "default-env",
}),
).toEqual({
environmentId: "agent-env",
source: "workspace",
conflict: null,
});
});
it("refuses silent reuse when the persisted workspace env disagrees with the assignee (PAPA-380: sandbox agent on local workspace)", () => {
// Claude E2B was assigned to a child issue whose parent had already
// realized a `Local` workspace. The persisted workspace env must not
// shadow the agent's intended sandbox env.
expect(
resolveExecutionWorkspaceEnvironmentId({
projectPolicy: { enabled: true, environmentId: null },
issueSettings: { environmentId: "sandbox-env", mode: "shared_workspace" },
workspaceConfig: { environmentId: "local-env" },
agentDefaultEnvironmentId: "sandbox-env",
defaultEnvironmentId: "local-env",
}),
).toEqual({
environmentId: "sandbox-env",
source: "issue",
conflict: {
reason: "reused_workspace_environment_mismatch",
workspaceEnvironmentId: "local-env",
assigneeIntendedEnvironmentId: "sandbox-env",
assigneeIntendedSource: "issue",
},
});
});
it("refuses silent reuse when a null-default (local) agent inherits a non-local workspace env (PAPA-431: Manual QA on engineer SSH workspace)", () => {
// Manual QA agent has defaultEnvironmentId: null. When a sibling issue's
// SSH workspace is inherited via inheritExecutionWorkspaceFromIssueId,
// the persisted SSH env must NOT shadow the agent's deliberate local
// identity. The inherited issueSettings.environmentId is treated as a
// promoted artifact, not an explicit operator choice.
expect(
resolveExecutionWorkspaceEnvironmentId({
projectPolicy: { enabled: true, environmentId: null },
issueSettings: { environmentId: "ssh-env", mode: "isolated_workspace" },
workspaceConfig: { environmentId: "ssh-env" },
agentDefaultEnvironmentId: null,
defaultEnvironmentId: "local-env",
}),
).toEqual({
environmentId: "local-env",
source: "default",
conflict: {
reason: "reused_workspace_environment_mismatch",
workspaceEnvironmentId: "ssh-env",
assigneeIntendedEnvironmentId: "local-env",
assigneeIntendedSource: "default",
},
});
});
it("honors an explicit issue env override for null-default agents when no workspace is being reused", () => {
// Operator explicitly chose an env on this issue via PATCH (see the
// issues-service contract at issues-service.test.ts:1924). For null-default
// agents, this is a deliberate choice — only inherited issue env (which
// matches a reused workspace env) should be discarded.
expect(
resolveExecutionWorkspaceEnvironmentId({
projectPolicy: { enabled: true, environmentId: "project-env" },
issueSettings: { environmentId: "issue-env" },
workspaceConfig: { environmentId: "workspace-env" },
agentDefaultEnvironmentId: "agent-env",
defaultEnvironmentId: "default-env",
workspaceConfig: null,
agentDefaultEnvironmentId: null,
defaultEnvironmentId: "local-env",
}),
).toBe("workspace-env");
).toEqual({
environmentId: "issue-env",
source: "issue",
conflict: null,
});
});
it("honors an explicit issue env override for null-default agents even against a disagreeing reused workspace", () => {
// Operator picked sandbox-env explicitly while the previously-realized
// workspace was on local-env. The mismatch is genuine — surface a conflict
// so the heartbeat forces a fresh realization on the operator's chosen env.
expect(
resolveExecutionWorkspaceEnvironmentId({
projectPolicy: { enabled: true, environmentId: null },
issueSettings: { environmentId: "sandbox-env", mode: "shared_workspace" },
workspaceConfig: { environmentId: "local-env" },
agentDefaultEnvironmentId: null,
defaultEnvironmentId: "local-env",
}),
).toEqual({
environmentId: "sandbox-env",
source: "issue",
conflict: {
reason: "reused_workspace_environment_mismatch",
workspaceEnvironmentId: "local-env",
assigneeIntendedEnvironmentId: "sandbox-env",
assigneeIntendedSource: "issue",
},
});
});
it("prefers the explicit issue environment over project and agent defaults when no workspace is reused", () => {
expect(
resolveExecutionWorkspaceEnvironmentId({
projectPolicy: { enabled: true, environmentId: "project-env" },
@ -166,7 +267,11 @@ describe("execution workspace policy helpers", () => {
agentDefaultEnvironmentId: "agent-env",
defaultEnvironmentId: "default-env",
}),
).toBe("issue-env");
).toEqual({
environmentId: "issue-env",
source: "issue",
conflict: null,
});
expect(
resolveExecutionWorkspaceEnvironmentId({
projectPolicy: { enabled: true, environmentId: "project-env" },
@ -175,7 +280,11 @@ describe("execution workspace policy helpers", () => {
agentDefaultEnvironmentId: "agent-env",
defaultEnvironmentId: "default-env",
}),
).toBe("project-env");
).toEqual({
environmentId: "project-env",
source: "project",
conflict: null,
});
});
it("falls back to the agent default environment before the company default", () => {
@ -187,7 +296,11 @@ describe("execution workspace policy helpers", () => {
agentDefaultEnvironmentId: "agent-env",
defaultEnvironmentId: "default-env",
}),
).toBe("agent-env");
).toEqual({
environmentId: "agent-env",
source: "agent",
conflict: null,
});
expect(
resolveExecutionWorkspaceEnvironmentId({
projectPolicy: { enabled: true, environmentId: null },
@ -196,7 +309,11 @@ describe("execution workspace policy helpers", () => {
agentDefaultEnvironmentId: "agent-env",
defaultEnvironmentId: "default-env",
}),
).toBe("default-env");
).toEqual({
environmentId: "default-env",
source: "project",
conflict: null,
});
expect(
resolveExecutionWorkspaceEnvironmentId({
projectPolicy: null,
@ -205,7 +322,11 @@ describe("execution workspace policy helpers", () => {
agentDefaultEnvironmentId: null,
defaultEnvironmentId: "default-env",
}),
).toBe("default-env");
).toEqual({
environmentId: "default-env",
source: "default",
conflict: null,
});
expect(
resolveExecutionWorkspaceEnvironmentId({
projectPolicy: { enabled: true, environmentId: null },
@ -214,7 +335,11 @@ describe("execution workspace policy helpers", () => {
agentDefaultEnvironmentId: null,
defaultEnvironmentId: "default-env",
}),
).toBe("default-env");
).toEqual({
environmentId: "default-env",
source: "default",
conflict: null,
});
});
it("maps persisted execution workspace modes back to issue settings", () => {

View file

@ -13,6 +13,7 @@ import {
documents,
environmentLeases,
environments,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
@ -20,6 +21,7 @@ import {
issueRelations,
issueTreeHolds,
issues,
workspaceOperations,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@ -142,6 +144,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
await db.delete(agents);
await db.delete(companySkills);
await db.delete(environments);
await db.delete(workspaceOperations);
await db.delete(executionWorkspaces);
await db.delete(companies);
});

View file

@ -19,6 +19,7 @@ import {
documents,
environmentLeases,
environments,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
@ -31,6 +32,7 @@ import {
issueTreeHolds,
issueWorkProducts,
issues,
workspaceOperations,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@ -378,6 +380,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
for (let attempt = 0; attempt < 5; attempt += 1) {
await db.delete(companySkills);
await db.delete(workspaceOperations);
await db.delete(executionWorkspaces);
await db.delete(issuePlanDecompositions);
await db.delete(issueThreadInteractions);
await db.delete(documentAnnotationComments);

View file

@ -7,6 +7,7 @@ import {
createDb,
documentRevisions,
documents,
executionWorkspaces,
goals,
heartbeatRuns,
issueComments,
@ -15,6 +16,9 @@ import {
issueRelations,
issueThreadInteractions,
issues,
projectWorkspaces,
projects,
workspaceOperations,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@ -48,7 +52,11 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
await db.delete(documents);
await db.delete(issueRelations);
await db.delete(heartbeatRuns);
await db.delete(workspaceOperations);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(goals);
await db.delete(agents);
await db.delete(instanceSettings);
@ -1135,4 +1143,262 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
},
});
});
describe("workspace_finalize accept gate", () => {
async function seedAcceptGateFixture() {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const issueId = randomUUID();
const goalId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Workspace",
sourceType: "local_path",
visibility: "default",
isPrimary: true,
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "exec",
status: "active",
providerType: "git_worktree",
});
await db.insert(goals).values({
id: goalId,
companyId,
title: "Accept gate fixture",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
goalId,
title: "Issue with execution workspace",
status: "in_progress",
priority: "medium",
executionWorkspaceId,
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Mark this issue done?",
},
}, {
userId: "local-board",
});
return { companyId, projectId, executionWorkspaceId, issueId, goalId, interactionId: created.id };
}
it("refuses accept when the issue's latest workspace operation is not a successful workspace_finalize", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
// A run touched the workspace (prepare) but never recorded workspace_finalize.
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
await expect(
interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
interactionId,
{},
{ userId: "local-board" },
),
).rejects.toMatchObject({
status: 409,
details: { executionWorkspaceId },
});
const row = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, interactionId))
.then((rows) => rows[0]);
expect(row?.status).toBe("pending");
});
it("refuses accept when the latest workspace operation is a failed workspace_finalize", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "workspace_finalize",
status: "failed",
startedAt: new Date("2026-05-23T22:05:00.000Z"),
});
await expect(
interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
interactionId,
{},
{ userId: "local-board" },
),
).rejects.toMatchObject({
status: 409,
details: { executionWorkspaceId },
});
const row = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, interactionId))
.then((rows) => rows[0]);
expect(row?.status).toBe("pending");
});
it("allows accept once a successful workspace_finalize lands as the latest operation", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "workspace_finalize",
status: "failed",
startedAt: new Date("2026-05-23T22:05:00.000Z"),
});
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
});
const accepted = await interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
interactionId,
{},
{ userId: "local-board" },
);
expect(accepted.interaction).toMatchObject({
id: interactionId,
status: "accepted",
});
});
it("allows accept of suggest_tasks even when no successful workspace_finalize has landed", async () => {
// suggest_tasks acceptance only creates follow-up issues; it does not
// approve code state or move the source workspace forward, so the
// workspace_finalize gate (PAPA-440) must not apply here. Without this
// carve-out the board cannot triage suggested tasks on an issue whose
// latest workspace op is still worktree_prepare.
const { companyId, executionWorkspaceId, issueId, goalId } = await seedAcceptGateFixture();
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-28T22:00:00.000Z"),
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "suggest_tasks",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
tasks: [
{
clientKey: "follow-up",
title: "Created from suggest_tasks accept under prepare-only workspace",
},
],
},
}, {
userId: "local-board",
});
const accepted = await interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
created.id,
{},
{ userId: "local-board" },
);
expect(accepted.interaction).toMatchObject({
id: created.id,
kind: "suggest_tasks",
status: "accepted",
});
});
it("allows accept when the issue has no execution workspace attached", async () => {
const { companyId, issueId } = await seedConfirmationIssue("No execution workspace accept");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Mark this issue done?",
},
}, {
userId: "local-board",
});
const accepted = await interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId: null, projectId: null },
created.id,
{},
{ userId: "local-board" },
);
expect(accepted.interaction).toMatchObject({
id: created.id,
status: "accepted",
});
});
});
});

View file

@ -23,6 +23,7 @@ import {
issues,
projectWorkspaces,
projects,
workspaceOperations,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@ -2283,6 +2284,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(workspaceOperations);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
@ -2452,6 +2454,179 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
]);
});
it("gates dependents on the workspace-finalize barrier when a done blocker's execution workspace has not synced back", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "QA",
role: "qa",
status: "active",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Shared workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Shared workspace",
sourceType: "local_path",
visibility: "default",
isPrimary: true,
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Shared exec workspace",
status: "active",
providerType: "git_worktree",
});
const blockerId = randomUUID();
const dependentId = randomUUID();
await db.insert(issues).values([
{
id: blockerId,
companyId,
projectId,
title: "Predecessor",
status: "done",
priority: "medium",
executionWorkspaceId,
},
{
id: dependentId,
companyId,
projectId,
title: "Dependent",
status: "blocked",
priority: "medium",
assigneeAgentId,
},
]);
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
// A run touched the workspace (prepare phase) but has not yet recorded
// workspace_finalize — the dependent must NOT wake.
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
isDependencyReady: false,
pendingFinalizeBlockerIssueIds: [blockerId],
unresolvedBlockerIssueIds: [blockerId],
});
// A failed finalize must keep the gate closed.
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "workspace_finalize",
status: "failed",
startedAt: new Date("2026-05-23T22:05:00.000Z"),
});
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
// Once a workspace_finalize succeeded row lands AFTER the failed one,
// the gate opens and the dependent is wakeable.
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
});
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
expect.objectContaining({
id: dependentId,
assigneeAgentId,
blockerIssueIds: [blockerId],
}),
]);
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
isDependencyReady: true,
pendingFinalizeBlockerIssueIds: [],
});
});
it("treats blockers with no executionWorkspaceId as not subject to the workspace-finalize barrier", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "QA",
role: "qa",
status: "active",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const blockerId = randomUUID();
const dependentId = randomUUID();
await db.insert(issues).values([
// Done blocker with no execution workspace ever attached (e.g. closed manually).
{ id: blockerId, companyId, title: "Manual done blocker", status: "done", priority: "medium" },
{
id: dependentId,
companyId,
title: "Dependent",
status: "blocked",
priority: "medium",
assigneeAgentId,
},
]);
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
// No executionWorkspaceId → no barrier → dependent should be wakeable.
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
expect.objectContaining({
id: dependentId,
assigneeAgentId,
blockerIssueIds: [blockerId],
}),
]);
});
it("reports dependency readiness for blocked issue chains", async () => {
const companyId = randomUUID();
await db.insert(companies).values({

View file

@ -119,26 +119,126 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti
};
}
export type ExecutionWorkspaceEnvironmentSource =
| "workspace"
| "issue"
| "project"
| "agent"
| "default";
export type ExecutionWorkspaceEnvironmentConflict = {
reason: "reused_workspace_environment_mismatch";
workspaceEnvironmentId: string;
assigneeIntendedEnvironmentId: string;
assigneeIntendedSource: Exclude<ExecutionWorkspaceEnvironmentSource, "workspace">;
};
export type ExecutionWorkspaceEnvironmentResolution = {
environmentId: string;
source: ExecutionWorkspaceEnvironmentSource;
conflict: ExecutionWorkspaceEnvironmentConflict | null;
};
function resolveAssigneeIntendedExecutionWorkspaceEnvironment(input: {
projectPolicy: ProjectExecutionWorkspacePolicy | null;
issueSettings: IssueExecutionWorkspaceSettings | null;
agentDefaultEnvironmentId: string | null;
defaultEnvironmentId: string;
}): {
environmentId: string;
source: Exclude<ExecutionWorkspaceEnvironmentSource, "workspace">;
} {
// Explicit issue-level env override always wins, even for null-default
// (local-only) agents. An operator who deliberately set
// `executionWorkspaceSettings.environmentId` on this specific issue (see the
// issues-service contract preserved in issues.ts:4243) chose that env for
// this assignment and should not be silently downgraded to the local default
// (PAPA-430 review fix). Inherited issue envs from
// `inheritExecutionWorkspaceFromIssueId` are stripped before this point in
// `resolveExecutionWorkspaceEnvironmentId`.
if (input.issueSettings?.environmentId !== undefined) {
return {
environmentId: input.issueSettings.environmentId ?? input.defaultEnvironmentId,
source: "issue",
};
}
// A null defaultEnvironmentId on the agent means it is deliberately scoped to
// the local default (e.g. Manual QA today). Project policy must not promote
// such an agent off of local — only an explicit issue-level override above
// can move the assignee away from the local default.
if (input.agentDefaultEnvironmentId === null) {
return { environmentId: input.defaultEnvironmentId, source: "default" };
}
if (input.projectPolicy?.environmentId !== undefined) {
return {
environmentId: input.projectPolicy.environmentId ?? input.defaultEnvironmentId,
source: "project",
};
}
return { environmentId: input.agentDefaultEnvironmentId, source: "agent" };
}
export function resolveExecutionWorkspaceEnvironmentId(input: {
projectPolicy: ProjectExecutionWorkspacePolicy | null;
issueSettings: IssueExecutionWorkspaceSettings | null;
workspaceConfig: { environmentId?: string | null } | null;
agentDefaultEnvironmentId: string | null;
defaultEnvironmentId: string;
}) {
}): ExecutionWorkspaceEnvironmentResolution {
// PAPA-431 companion: when the assignee has no explicit defaultEnvironmentId
// (deliberately local-only, e.g. Manual QA) AND the issue settings env exactly
// matches the reused workspace env, treat the issue env as a promoted artifact
// from `inheritExecutionWorkspaceFromIssueId` rather than a deliberate
// operator choice. Strip it so the resolver falls back to the local default
// and the workspace-vs-intended conflict check forces a fresh realization.
// A genuine operator override (via PATCH on the issue) reaches this code path
// either with no reused workspace (workspaceConfig === null) or against a
// workspace whose persisted env does not match the new override; both keep
// the issue setting in place.
const inheritedIssueEnvOnNullDefaultAssignee =
input.agentDefaultEnvironmentId === null &&
input.workspaceConfig?.environmentId !== undefined &&
input.workspaceConfig?.environmentId !== null &&
input.issueSettings?.environmentId !== undefined &&
input.issueSettings.environmentId === input.workspaceConfig.environmentId;
let issueSettingsForResolution = input.issueSettings;
if (inheritedIssueEnvOnNullDefaultAssignee && input.issueSettings) {
const { environmentId: _droppedInheritedEnv, ...rest } = input.issueSettings;
void _droppedInheritedEnv;
issueSettingsForResolution = rest as IssueExecutionWorkspaceSettings;
}
const assigneeIntended = resolveAssigneeIntendedExecutionWorkspaceEnvironment({
projectPolicy: input.projectPolicy,
issueSettings: issueSettingsForResolution,
agentDefaultEnvironmentId: input.agentDefaultEnvironmentId,
defaultEnvironmentId: input.defaultEnvironmentId,
});
if (input.workspaceConfig?.environmentId !== undefined) {
return input.workspaceConfig.environmentId ?? input.defaultEnvironmentId;
const workspaceEnvironmentId =
input.workspaceConfig.environmentId ?? input.defaultEnvironmentId;
// PAPA-380 / PAPA-431: a reused workspace's persisted environmentId must
// never silently shadow the current assignee's environment identity.
// When they disagree, refuse the silent reuse: return the assignee's
// intended env and surface a conflict signal so the caller forces a fresh
// workspace realization (or otherwise alerts the operator) instead of
// running the agent on someone else's environment.
if (workspaceEnvironmentId !== assigneeIntended.environmentId) {
return {
environmentId: assigneeIntended.environmentId,
source: assigneeIntended.source,
conflict: {
reason: "reused_workspace_environment_mismatch",
workspaceEnvironmentId,
assigneeIntendedEnvironmentId: assigneeIntended.environmentId,
assigneeIntendedSource: assigneeIntended.source,
},
};
}
return { environmentId: workspaceEnvironmentId, source: "workspace", conflict: null };
}
if (input.issueSettings?.environmentId !== undefined) {
return input.issueSettings.environmentId ?? input.defaultEnvironmentId;
}
if (input.projectPolicy?.environmentId !== undefined) {
return input.projectPolicy.environmentId ?? input.defaultEnvironmentId;
}
if (input.agentDefaultEnvironmentId !== null) {
return input.agentDefaultEnvironmentId;
}
return input.defaultEnvironmentId;
return { environmentId: assigneeIntended.environmentId, source: assigneeIntended.source, conflict: null };
}
export function defaultIssueExecutionWorkspaceSettingsForProject(

View file

@ -7276,13 +7276,47 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
const existingExecutionWorkspace =
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
const shouldReuseExisting =
const requestedShouldReuseExisting =
issueRef?.executionWorkspacePreference === "reuse_existing" &&
existingExecutionWorkspace !== null &&
existingExecutionWorkspace.status !== "archived";
const reusableExecutionWorkspaceConfig = shouldReuseExisting
const requestedReusableExecutionWorkspaceConfig = requestedShouldReuseExisting
? existingExecutionWorkspace?.config ?? null
: null;
const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
const environmentResolution = resolveExecutionWorkspaceEnvironmentId({
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
workspaceConfig: requestedReusableExecutionWorkspaceConfig,
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
defaultEnvironmentId: defaultEnvironment.id,
});
// PAPA-380 / PAPA-431: when the resolver refuses silent reuse of the
// persisted workspace environment, also force a fresh workspace
// realization on the assignee's intended env. Reusing the on-disk
// workspace while swapping the env underneath it would mismatch the cwd's
// runtime expectations (e.g. an SSH-targeted worktree running on the
// local default driver).
if (environmentResolution.conflict) {
logger.warn(
{
runId: run.id,
issueId,
agentId: agent.id,
adapterType: agent.adapterType,
existingExecutionWorkspaceId: existingExecutionWorkspace?.id ?? null,
workspaceEnvironmentId: environmentResolution.conflict.workspaceEnvironmentId,
assigneeIntendedEnvironmentId:
environmentResolution.conflict.assigneeIntendedEnvironmentId,
assigneeIntendedSource: environmentResolution.conflict.assigneeIntendedSource,
},
"Refusing silent reuse of execution workspace whose environment does not match the assignee's intended environment; forcing fresh realization",
);
}
const shouldReuseExisting = requestedShouldReuseExisting && !environmentResolution.conflict;
const reusableExecutionWorkspaceConfig = shouldReuseExisting
? requestedReusableExecutionWorkspaceConfig
: null;
const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace
? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode)
: null;
@ -7292,14 +7326,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
persistedExecutionWorkspaceMode === "agent_default"
? persistedExecutionWorkspaceMode
: requestedExecutionWorkspaceMode;
const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
const selectedEnvironmentId = resolveExecutionWorkspaceEnvironmentId({
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
workspaceConfig: reusableExecutionWorkspaceConfig,
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
defaultEnvironmentId: defaultEnvironment.id,
});
const selectedEnvironmentId = environmentResolution.environmentId;
const workspaceManagedConfig = shouldReuseExisting
? { ...config }
: buildExecutionWorkspaceAdapterConfig({
@ -7980,31 +8007,80 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
"local agent jwt secret missing or invalid; running without injected PAPERCLIP_API_KEY",
);
}
const adapterResult = await adapter.execute({
runId: run.id,
agent,
runtime: runtimeForAdapter,
config: runtimeConfig,
context,
runtimeCommandSpec: adapter.getRuntimeCommandSpec?.(runtimeConfig) ?? null,
executionTarget,
executionTransport: remoteExecution
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
: undefined,
onLog,
onMeta: onAdapterMeta,
onSpawn: async (meta) => {
await persistRunProcessMetadata(run.id, {
pid: meta.pid,
processGroupId:
"processGroupId" in meta && typeof meta.processGroupId === "number"
? meta.processGroupId
: null,
startedAt: meta.startedAt,
let adapterFinalizeOutcome: "succeeded" | "failed" | null = null;
const recordWorkspaceFinalize = async (
status: "succeeded" | "failed",
metadata?: Record<string, unknown>,
) => {
if (adapterFinalizeOutcome) return;
await workspaceOperationRecorder.recordOperation({
phase: "workspace_finalize",
cwd: executionWorkspace.cwd,
metadata: {
adapterType: agent.adapterType,
executionTargetKind: executionTarget?.kind ?? "local",
...metadata,
},
run: async () => ({ status }),
});
// Only mark the outcome after the row landed, so a transient write
// failure on the succeeded path can still be recovered by recording
// finalize=failed from the catch path below.
adapterFinalizeOutcome = status;
};
let adapterResult: Awaited<ReturnType<typeof adapter.execute>>;
try {
adapterResult = await adapter.execute({
runId: run.id,
agent,
runtime: runtimeForAdapter,
config: runtimeConfig,
context,
runtimeCommandSpec: adapter.getRuntimeCommandSpec?.(runtimeConfig) ?? null,
executionTarget,
executionTransport: remoteExecution
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
: undefined,
onLog,
onMeta: onAdapterMeta,
onSpawn: async (meta) => {
await persistRunProcessMetadata(run.id, {
pid: meta.pid,
processGroupId:
"processGroupId" in meta && typeof meta.processGroupId === "number"
? meta.processGroupId
: null,
startedAt: meta.startedAt,
});
},
authToken: authToken ?? undefined,
});
// Adapter returned cleanly, which means its workspace-restore finally
// block also ran without throwing. Record the workspace_finalize
// barrier so dependents that share this executionWorkspace can wake.
// If recording the barrier itself fails, propagate as a run failure
// rather than silently leaving dependents stranded behind a missing
// finalize row.
await recordWorkspaceFinalize("succeeded");
} catch (adapterErr) {
// Adapter (or its restore finally) threw — or the finalize record
// write itself threw. Either way the workspace may be in a partial
// state. Best-effort record finalize=failed so the dependent readiness
// check keeps the gate closed instead of waking on stale local state,
// and surface the original error to the caller.
try {
await recordWorkspaceFinalize("failed", {
errorMessage: adapterErr instanceof Error ? adapterErr.message : String(adapterErr),
});
},
authToken: authToken ?? undefined,
});
} catch (recordErr) {
logger.warn(
{ err: recordErr, runId: run.id, executionWorkspaceId: persistedExecutionWorkspace?.id ?? null },
"failed to record workspace_finalize=failed operation; dependents may remain gated",
);
}
throw adapterErr;
}
const adapterManagedRuntimeServices = adapterResult.runtimeServices
? await persistAdapterManagedRuntimeServices({
db,
@ -8250,6 +8326,54 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
: livenessRun,
agent,
);
// Workspace-finalize wake re-fire: if this run's issue was marked done
// mid-run (so the original `issue_blockers_resolved` wake was gated by
// the readiness check waiting for workspace_finalize), the finalize
// row we just recorded now lets dependents proceed. Fire wakes here.
if (issueId && adapterFinalizeOutcome === "succeeded") {
try {
const blockerIssueStatus = await db
.select({ status: issues.status })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]?.status ?? null);
if (blockerIssueStatus === "done") {
const dependents = await issuesSvc.listWakeableBlockedDependents(issueId);
for (const dependent of dependents) {
await enqueueWakeup(dependent.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: {
issueId: dependent.id,
resolvedBlockerIssueId: issueId,
blockerIssueIds: dependent.blockerIssueIds,
deferredFor: "workspace_finalize",
},
contextSnapshot: {
issueId: dependent.id,
taskId: dependent.id,
wakeReason: "issue_blockers_resolved",
source: "workspace.finalize",
resolvedBlockerIssueId: issueId,
blockerIssueIds: dependent.blockerIssueIds,
},
}).catch((wakeErr) => {
logger.warn(
{ err: wakeErr, issueId, dependentIssueId: dependent.id, agentId: dependent.assigneeAgentId },
"failed to fire deferred dependent wake after workspace_finalize",
);
});
}
}
} catch (finalizeWakeErr) {
logger.warn(
{ err: finalizeWakeErr, runId: run.id, issueId },
"failed to evaluate dependent wakes after workspace_finalize",
);
}
}
}
if (finalizedRun) {

View file

@ -36,7 +36,7 @@ import {
suggestTasksResultSchema,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { issueService } from "./issues.js";
import { issueService, listUnfinalizedExecutionWorkspaceIds } from "./issues.js";
type InteractionActor = {
agentId?: string | null;
@ -457,6 +457,32 @@ export function issueThreadInteractionService(db: Db) {
.then((rows) => rows[0] ?? null);
}
async function assertIssueWorkspaceFinalizedForAccept(args: {
db: Pick<Db, "select">;
issue: { id: string; companyId: string };
}) {
const executionWorkspaceId = await args.db
.select({ executionWorkspaceId: issues.executionWorkspaceId })
.from(issues)
.where(eq(issues.id, args.issue.id))
.then((rows: Array<{ executionWorkspaceId: string | null }>) => rows[0]?.executionWorkspaceId ?? null);
if (!executionWorkspaceId) return;
const unfinalized = await listUnfinalizedExecutionWorkspaceIds(
args.db,
args.issue.companyId,
[executionWorkspaceId],
);
if (!unfinalized.has(executionWorkspaceId)) return;
throw conflict(
"Cannot accept interaction: the issue's most recent run has not completed workspace_finalize. "
+ "Retry once the local worktree has finished syncing.",
{ executionWorkspaceId },
);
}
async function getPendingInteractionForResolution(args: {
issue: { id: string; companyId: string };
interactionId: string;
@ -747,8 +773,12 @@ export function issueThreadInteractionService(db: Db) {
const current = await getPendingInteractionForResolution({ issue, interactionId });
switch (current.kind) {
case "suggest_tasks":
// Accepting suggest_tasks only creates follow-up issues; it does not
// approve code state or move the source workspace forward, so the
// workspace_finalize gate (PAPA-440) does not apply here.
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor);
case "request_confirmation": {
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
const accepted = await acceptRequestConfirmation({
issue,
current,

View file

@ -30,6 +30,7 @@ import {
labels,
projectWorkspaces,
projects,
workspaceOperations,
} from "@paperclipai/db";
import type {
AcceptedPlanDecomposition,
@ -351,6 +352,8 @@ export type IssueDependencyReadiness = {
blockerIssueIds: string[];
unresolvedBlockerIssueIds: string[];
unresolvedBlockerCount: number;
/** Blockers whose status is `done` but whose execution workspace has not yet finalized. */
pendingFinalizeBlockerIssueIds: string[];
allBlockersDone: boolean;
isDependencyReady: boolean;
};
@ -582,11 +585,70 @@ function createIssueDependencyReadiness(issueId: string): IssueDependencyReadine
blockerIssueIds: [],
unresolvedBlockerIssueIds: [],
unresolvedBlockerCount: 0,
pendingFinalizeBlockerIssueIds: [],
allBlockersDone: true,
isDependencyReady: true,
};
}
/**
* Returns the set of execution-workspace ids whose most recent workspace operation
* is NOT a successful `workspace_finalize`. These workspaces have either an in-flight
* run, a failed finalize, or never reached the finalize barrier dependents that
* read this workspace must wait until finalize succeeds.
*
* Workspaces with no recorded operations are considered finalized (nothing has
* touched them since they were realized).
*/
export async function listUnfinalizedExecutionWorkspaceIds(
dbOrTx: Pick<Db, "select">,
companyId: string,
executionWorkspaceIds: string[],
): Promise<Set<string>> {
const unfinalized = new Set<string>();
if (executionWorkspaceIds.length === 0) return unfinalized;
// Pull every workspace op for the candidate workspaces and pick the latest per
// workspace in memory. Per-workspace LATERAL queries would be tighter, but the
// candidate set is tiny in practice (one workspace per blocker per readiness call).
const rows = await dbOrTx
.select({
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
phase: workspaceOperations.phase,
status: workspaceOperations.status,
startedAt: workspaceOperations.startedAt,
})
.from(workspaceOperations)
.where(
and(
eq(workspaceOperations.companyId, companyId),
inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds),
),
);
const latestByWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
for (const row of rows) {
if (!row.executionWorkspaceId) continue;
const current = latestByWorkspace.get(row.executionWorkspaceId);
if (!current || row.startedAt > current.startedAt) {
latestByWorkspace.set(row.executionWorkspaceId, {
phase: row.phase,
status: row.status,
startedAt: row.startedAt,
});
}
}
for (const workspaceId of executionWorkspaceIds) {
const latest = latestByWorkspace.get(workspaceId);
if (!latest) continue; // no ops recorded → treat as finalized
if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue;
unfinalized.add(workspaceId);
}
return unfinalized;
}
async function listIssueDependencyReadinessMap(
dbOrTx: Pick<Db, "select">,
companyId: string,
@ -604,6 +666,7 @@ async function listIssueDependencyReadinessMap(
issueId: issueRelations.relatedIssueId,
blockerIssueId: issueRelations.issueId,
blockerStatus: issues.status,
blockerExecutionWorkspaceId: issues.executionWorkspaceId,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
@ -615,6 +678,21 @@ async function listIssueDependencyReadinessMap(
),
);
// Collect executionWorkspaceIds of "done" blockers — these are the only ones
// subject to the workspace-finalize barrier. Blockers that aren't done already
// mark the dependent as not-ready and don't need a finalize check.
const doneBlockerWorkspaceIds = new Set<string>();
for (const row of blockerRows) {
if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) {
doneBlockerWorkspaceIds.add(row.blockerExecutionWorkspaceId);
}
}
const unfinalizedWorkspaceIds = await listUnfinalizedExecutionWorkspaceIds(
dbOrTx,
companyId,
[...doneBlockerWorkspaceIds],
);
for (const row of blockerRows) {
const current = readinessMap.get(row.issueId) ?? createIssueDependencyReadiness(row.issueId);
current.blockerIssueIds.push(row.blockerIssueId);
@ -625,6 +703,21 @@ async function listIssueDependencyReadinessMap(
current.unresolvedBlockerCount += 1;
current.allBlockersDone = false;
current.isDependencyReady = false;
} else if (
row.blockerExecutionWorkspaceId &&
unfinalizedWorkspaceIds.has(row.blockerExecutionWorkspaceId)
) {
// Workspace-finalize barrier: the blocker's most recent run on its
// execution workspace hasn't recorded a successful workspace_finalize.
// Treat the dependent as not-ready until sync-back lands (or the run
// finalizes); a subsequent finalize wake will re-evaluate readiness.
// `allBlockersDone` is cleared too so that callers using it as a
// proxy for "this dependent can proceed" still see the gate.
current.unresolvedBlockerIssueIds.push(row.blockerIssueId);
current.unresolvedBlockerCount += 1;
current.pendingFinalizeBlockerIssueIds.push(row.blockerIssueId);
current.allBlockersDone = false;
current.isDependencyReady = false;
}
readinessMap.set(row.issueId, current);
}
@ -4091,45 +4184,33 @@ export function issueService(db: Db) {
);
if (candidates.length === 0) return [];
const candidateIds = candidates.map((candidate) => candidate.id);
const blockerRows = await db
.select({
issueId: issueRelations.relatedIssueId,
blockerIssueId: issueRelations.issueId,
blockerStatus: issues.status,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
.where(
and(
eq(issueRelations.companyId, blockerIssue.companyId),
eq(issueRelations.type, "blocks"),
inArray(issueRelations.relatedIssueId, candidateIds),
),
);
const wakeableCandidates = candidates.filter(
(candidate) =>
candidate.assigneeAgentId && !["backlog", "done", "cancelled"].includes(candidate.status),
);
if (wakeableCandidates.length === 0) return [];
const blockersByIssueId = new Map<string, Array<{ blockerIssueId: string; blockerStatus: string }>>();
for (const row of blockerRows) {
const list = blockersByIssueId.get(row.issueId) ?? [];
list.push({ blockerIssueId: row.blockerIssueId, blockerStatus: row.blockerStatus });
blockersByIssueId.set(row.issueId, list);
}
// Defer to the unified readiness check so that a dependent only fires when
// (a) every blocker is done AND (b) every done blocker's workspace has
// recorded a successful workspace_finalize. The finalize hook also calls
// this function on completion, so a wake initially gated by an in-flight
// sync-back will re-fire once the restore lands locally.
const readinessMap = await listIssueDependencyReadinessMap(
db,
blockerIssue.companyId,
wakeableCandidates.map((candidate) => candidate.id),
);
return candidates
.filter((candidate) => candidate.assigneeAgentId && !["backlog", "done", "cancelled"].includes(candidate.status))
return wakeableCandidates
.map((candidate) => {
const blockers = blockersByIssueId.get(candidate.id) ?? [];
return {
...candidate,
blockerIssueIds: blockers.map((blocker) => blocker.blockerIssueId),
allBlockersDone: blockers.length > 0 && blockers.every((blocker) => blocker.blockerStatus === "done"),
};
const readiness = readinessMap.get(candidate.id) ?? createIssueDependencyReadiness(candidate.id);
return { candidate, readiness };
})
.filter((candidate) => candidate.allBlockersDone)
.map((candidate) => ({
.filter(({ readiness }) => readiness.isDependencyReady && readiness.blockerIssueIds.length > 0)
.map(({ candidate, readiness }) => ({
id: candidate.id,
assigneeAgentId: candidate.assigneeAgentId!,
blockerIssueIds: candidate.blockerIssueIds,
blockerIssueIds: readiness.blockerIssueIds,
}));
},