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

@ -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) {