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

@ -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,
}));
},