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:
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user