forked from farhoodlabs/paperclip
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:
@@ -45,6 +45,12 @@ jobs:
|
|||||||
- name: Validate Dockerfile deps stage
|
- name: Validate Dockerfile deps stage
|
||||||
run: node ./scripts/check-docker-deps-stage.mjs
|
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
|
- name: Validate release package manifest
|
||||||
run: node ./scripts/release-package-map.mjs check
|
run: node ./scripts/release-package-map.mjs check
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
## Security
|
||||||
|
|
||||||
- Treat agent output as untrusted (parse defensively, never execute)
|
- Treat agent output as untrusted (parse defensively, never execute)
|
||||||
|
|||||||
@@ -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.
|
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.
|
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
|
## Current implementation guarantees
|
||||||
|
|
||||||
With the current implementation:
|
With the current implementation:
|
||||||
|
|||||||
+3
-1
@@ -35,12 +35,14 @@
|
|||||||
"release:rollback": "./scripts/rollback-latest.sh",
|
"release:rollback": "./scripts/rollback-latest.sh",
|
||||||
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
|
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
|
||||||
"check:tokens": "node scripts/check-forbidden-tokens.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",
|
"docs:dev": "cd docs && npx mintlify dev",
|
||||||
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
|
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
|
||||||
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
|
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
|
||||||
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.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",
|
"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": "npx playwright test --config tests/e2e/playwright.config.ts",
|
||||||
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
|
"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",
|
"test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",
|
||||||
|
|||||||
@@ -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");
|
await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n");
|
||||||
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
}, 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 () => {
|
it("merges concurrent remote commits through the managed runtime restore path", async () => {
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||||
cleanupDirs.push(rootDir);
|
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
|
- `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames
|
||||||
|
|
||||||
UI/CLI parsers consume these lines to render transcript updates.
|
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,
|
"when": 1779999768200,
|
||||||
"tag": "0092_mighty_puma",
|
"tag": "0092_mighty_puma",
|
||||||
"breakpoints": true
|
"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",
|
"execution_workspaces",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
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" }),
|
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
||||||
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
|
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
|
||||||
sourceIssueId: uuid("source_issue_id").references((): AnyPgColumn => issues.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",
|
"workspace_operations",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
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, {
|
executionWorkspaceId: uuid("execution_workspace_id").references(() => executionWorkspaces.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ export type WorkspaceOperationPhase =
|
|||||||
| "worktree_prepare"
|
| "worktree_prepare"
|
||||||
| "workspace_provision"
|
| "workspace_provision"
|
||||||
| "workspace_teardown"
|
| "workspace_teardown"
|
||||||
| "worktree_cleanup";
|
| "worktree_cleanup"
|
||||||
|
| "workspace_finalize";
|
||||||
|
|
||||||
export type WorkspaceOperationStatus = "running" | "succeeded" | "failed" | "skipped";
|
export type WorkspaceOperationStatus = "running" | "succeeded" | "failed" | "skipped";
|
||||||
|
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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(
|
expect(
|
||||||
resolveExecutionWorkspaceEnvironmentId({
|
resolveExecutionWorkspaceEnvironmentId({
|
||||||
projectPolicy: { enabled: true, environmentId: "project-env" },
|
projectPolicy: { enabled: true, environmentId: "project-env" },
|
||||||
issueSettings: { environmentId: "issue-env" },
|
issueSettings: { environmentId: "issue-env" },
|
||||||
workspaceConfig: { environmentId: "workspace-env" },
|
workspaceConfig: null,
|
||||||
agentDefaultEnvironmentId: "agent-env",
|
agentDefaultEnvironmentId: null,
|
||||||
defaultEnvironmentId: "default-env",
|
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(
|
expect(
|
||||||
resolveExecutionWorkspaceEnvironmentId({
|
resolveExecutionWorkspaceEnvironmentId({
|
||||||
projectPolicy: { enabled: true, environmentId: "project-env" },
|
projectPolicy: { enabled: true, environmentId: "project-env" },
|
||||||
@@ -166,7 +267,11 @@ describe("execution workspace policy helpers", () => {
|
|||||||
agentDefaultEnvironmentId: "agent-env",
|
agentDefaultEnvironmentId: "agent-env",
|
||||||
defaultEnvironmentId: "default-env",
|
defaultEnvironmentId: "default-env",
|
||||||
}),
|
}),
|
||||||
).toBe("issue-env");
|
).toEqual({
|
||||||
|
environmentId: "issue-env",
|
||||||
|
source: "issue",
|
||||||
|
conflict: null,
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
resolveExecutionWorkspaceEnvironmentId({
|
resolveExecutionWorkspaceEnvironmentId({
|
||||||
projectPolicy: { enabled: true, environmentId: "project-env" },
|
projectPolicy: { enabled: true, environmentId: "project-env" },
|
||||||
@@ -175,7 +280,11 @@ describe("execution workspace policy helpers", () => {
|
|||||||
agentDefaultEnvironmentId: "agent-env",
|
agentDefaultEnvironmentId: "agent-env",
|
||||||
defaultEnvironmentId: "default-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", () => {
|
it("falls back to the agent default environment before the company default", () => {
|
||||||
@@ -187,7 +296,11 @@ describe("execution workspace policy helpers", () => {
|
|||||||
agentDefaultEnvironmentId: "agent-env",
|
agentDefaultEnvironmentId: "agent-env",
|
||||||
defaultEnvironmentId: "default-env",
|
defaultEnvironmentId: "default-env",
|
||||||
}),
|
}),
|
||||||
).toBe("agent-env");
|
).toEqual({
|
||||||
|
environmentId: "agent-env",
|
||||||
|
source: "agent",
|
||||||
|
conflict: null,
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
resolveExecutionWorkspaceEnvironmentId({
|
resolveExecutionWorkspaceEnvironmentId({
|
||||||
projectPolicy: { enabled: true, environmentId: null },
|
projectPolicy: { enabled: true, environmentId: null },
|
||||||
@@ -196,7 +309,11 @@ describe("execution workspace policy helpers", () => {
|
|||||||
agentDefaultEnvironmentId: "agent-env",
|
agentDefaultEnvironmentId: "agent-env",
|
||||||
defaultEnvironmentId: "default-env",
|
defaultEnvironmentId: "default-env",
|
||||||
}),
|
}),
|
||||||
).toBe("default-env");
|
).toEqual({
|
||||||
|
environmentId: "default-env",
|
||||||
|
source: "project",
|
||||||
|
conflict: null,
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
resolveExecutionWorkspaceEnvironmentId({
|
resolveExecutionWorkspaceEnvironmentId({
|
||||||
projectPolicy: null,
|
projectPolicy: null,
|
||||||
@@ -205,7 +322,11 @@ describe("execution workspace policy helpers", () => {
|
|||||||
agentDefaultEnvironmentId: null,
|
agentDefaultEnvironmentId: null,
|
||||||
defaultEnvironmentId: "default-env",
|
defaultEnvironmentId: "default-env",
|
||||||
}),
|
}),
|
||||||
).toBe("default-env");
|
).toEqual({
|
||||||
|
environmentId: "default-env",
|
||||||
|
source: "default",
|
||||||
|
conflict: null,
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
resolveExecutionWorkspaceEnvironmentId({
|
resolveExecutionWorkspaceEnvironmentId({
|
||||||
projectPolicy: { enabled: true, environmentId: null },
|
projectPolicy: { enabled: true, environmentId: null },
|
||||||
@@ -214,7 +335,11 @@ describe("execution workspace policy helpers", () => {
|
|||||||
agentDefaultEnvironmentId: null,
|
agentDefaultEnvironmentId: null,
|
||||||
defaultEnvironmentId: "default-env",
|
defaultEnvironmentId: "default-env",
|
||||||
}),
|
}),
|
||||||
).toBe("default-env");
|
).toEqual({
|
||||||
|
environmentId: "default-env",
|
||||||
|
source: "default",
|
||||||
|
conflict: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps persisted execution workspace modes back to issue settings", () => {
|
it("maps persisted execution workspace modes back to issue settings", () => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
documents,
|
documents,
|
||||||
environmentLeases,
|
environmentLeases,
|
||||||
environments,
|
environments,
|
||||||
|
executionWorkspaces,
|
||||||
heartbeatRunEvents,
|
heartbeatRunEvents,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
issueComments,
|
issueComments,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
issueRelations,
|
issueRelations,
|
||||||
issueTreeHolds,
|
issueTreeHolds,
|
||||||
issues,
|
issues,
|
||||||
|
workspaceOperations,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
getEmbeddedPostgresTestSupport,
|
getEmbeddedPostgresTestSupport,
|
||||||
@@ -142,6 +144,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||||||
await db.delete(agents);
|
await db.delete(agents);
|
||||||
await db.delete(companySkills);
|
await db.delete(companySkills);
|
||||||
await db.delete(environments);
|
await db.delete(environments);
|
||||||
|
await db.delete(workspaceOperations);
|
||||||
|
await db.delete(executionWorkspaces);
|
||||||
await db.delete(companies);
|
await db.delete(companies);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
documents,
|
documents,
|
||||||
environmentLeases,
|
environmentLeases,
|
||||||
environments,
|
environments,
|
||||||
|
executionWorkspaces,
|
||||||
heartbeatRunEvents,
|
heartbeatRunEvents,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
issueComments,
|
issueComments,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
issueTreeHolds,
|
issueTreeHolds,
|
||||||
issueWorkProducts,
|
issueWorkProducts,
|
||||||
issues,
|
issues,
|
||||||
|
workspaceOperations,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
getEmbeddedPostgresTestSupport,
|
getEmbeddedPostgresTestSupport,
|
||||||
@@ -378,6 +380,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||||||
}
|
}
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
await db.delete(companySkills);
|
await db.delete(companySkills);
|
||||||
|
await db.delete(workspaceOperations);
|
||||||
|
await db.delete(executionWorkspaces);
|
||||||
await db.delete(issuePlanDecompositions);
|
await db.delete(issuePlanDecompositions);
|
||||||
await db.delete(issueThreadInteractions);
|
await db.delete(issueThreadInteractions);
|
||||||
await db.delete(documentAnnotationComments);
|
await db.delete(documentAnnotationComments);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createDb,
|
createDb,
|
||||||
documentRevisions,
|
documentRevisions,
|
||||||
documents,
|
documents,
|
||||||
|
executionWorkspaces,
|
||||||
goals,
|
goals,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
issueComments,
|
issueComments,
|
||||||
@@ -15,6 +16,9 @@ import {
|
|||||||
issueRelations,
|
issueRelations,
|
||||||
issueThreadInteractions,
|
issueThreadInteractions,
|
||||||
issues,
|
issues,
|
||||||
|
projectWorkspaces,
|
||||||
|
projects,
|
||||||
|
workspaceOperations,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
getEmbeddedPostgresTestSupport,
|
getEmbeddedPostgresTestSupport,
|
||||||
@@ -48,7 +52,11 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||||||
await db.delete(documents);
|
await db.delete(documents);
|
||||||
await db.delete(issueRelations);
|
await db.delete(issueRelations);
|
||||||
await db.delete(heartbeatRuns);
|
await db.delete(heartbeatRuns);
|
||||||
|
await db.delete(workspaceOperations);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
|
await db.delete(executionWorkspaces);
|
||||||
|
await db.delete(projectWorkspaces);
|
||||||
|
await db.delete(projects);
|
||||||
await db.delete(goals);
|
await db.delete(goals);
|
||||||
await db.delete(agents);
|
await db.delete(agents);
|
||||||
await db.delete(instanceSettings);
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
issues,
|
issues,
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
projects,
|
projects,
|
||||||
|
workspaceOperations,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
getEmbeddedPostgresTestSupport,
|
getEmbeddedPostgresTestSupport,
|
||||||
@@ -2283,6 +2284,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
|||||||
await db.delete(issueInboxArchives);
|
await db.delete(issueInboxArchives);
|
||||||
await db.delete(activityLog);
|
await db.delete(activityLog);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
|
await db.delete(workspaceOperations);
|
||||||
await db.delete(executionWorkspaces);
|
await db.delete(executionWorkspaces);
|
||||||
await db.delete(projectWorkspaces);
|
await db.delete(projectWorkspaces);
|
||||||
await db.delete(projects);
|
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 () => {
|
it("reports dependency readiness for blocked issue chains", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
await db.insert(companies).values({
|
await db.insert(companies).values({
|
||||||
|
|||||||
@@ -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: {
|
export function resolveExecutionWorkspaceEnvironmentId(input: {
|
||||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||||
workspaceConfig: { environmentId?: string | null } | null;
|
workspaceConfig: { environmentId?: string | null } | null;
|
||||||
agentDefaultEnvironmentId: string | null;
|
agentDefaultEnvironmentId: string | null;
|
||||||
defaultEnvironmentId: string;
|
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) {
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (input.issueSettings?.environmentId !== undefined) {
|
return { environmentId: workspaceEnvironmentId, source: "workspace", conflict: null };
|
||||||
return input.issueSettings.environmentId ?? input.defaultEnvironmentId;
|
|
||||||
}
|
}
|
||||||
if (input.projectPolicy?.environmentId !== undefined) {
|
return { environmentId: assigneeIntended.environmentId, source: assigneeIntended.source, conflict: null };
|
||||||
return input.projectPolicy.environmentId ?? input.defaultEnvironmentId;
|
|
||||||
}
|
|
||||||
if (input.agentDefaultEnvironmentId !== null) {
|
|
||||||
return input.agentDefaultEnvironmentId;
|
|
||||||
}
|
|
||||||
return input.defaultEnvironmentId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultIssueExecutionWorkspaceSettingsForProject(
|
export function defaultIssueExecutionWorkspaceSettingsForProject(
|
||||||
|
|||||||
@@ -7276,13 +7276,47 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
}
|
}
|
||||||
const existingExecutionWorkspace =
|
const existingExecutionWorkspace =
|
||||||
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
||||||
const shouldReuseExisting =
|
const requestedShouldReuseExisting =
|
||||||
issueRef?.executionWorkspacePreference === "reuse_existing" &&
|
issueRef?.executionWorkspacePreference === "reuse_existing" &&
|
||||||
existingExecutionWorkspace !== null &&
|
existingExecutionWorkspace !== null &&
|
||||||
existingExecutionWorkspace.status !== "archived";
|
existingExecutionWorkspace.status !== "archived";
|
||||||
const reusableExecutionWorkspaceConfig = shouldReuseExisting
|
const requestedReusableExecutionWorkspaceConfig = requestedShouldReuseExisting
|
||||||
? existingExecutionWorkspace?.config ?? null
|
? existingExecutionWorkspace?.config ?? null
|
||||||
: 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
|
const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace
|
||||||
? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode)
|
? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode)
|
||||||
: null;
|
: null;
|
||||||
@@ -7292,14 +7326,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
persistedExecutionWorkspaceMode === "agent_default"
|
persistedExecutionWorkspaceMode === "agent_default"
|
||||||
? persistedExecutionWorkspaceMode
|
? persistedExecutionWorkspaceMode
|
||||||
: requestedExecutionWorkspaceMode;
|
: requestedExecutionWorkspaceMode;
|
||||||
const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
|
const selectedEnvironmentId = environmentResolution.environmentId;
|
||||||
const selectedEnvironmentId = resolveExecutionWorkspaceEnvironmentId({
|
|
||||||
projectPolicy: projectExecutionWorkspacePolicy,
|
|
||||||
issueSettings: issueExecutionWorkspaceSettings,
|
|
||||||
workspaceConfig: reusableExecutionWorkspaceConfig,
|
|
||||||
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
|
|
||||||
defaultEnvironmentId: defaultEnvironment.id,
|
|
||||||
});
|
|
||||||
const workspaceManagedConfig = shouldReuseExisting
|
const workspaceManagedConfig = shouldReuseExisting
|
||||||
? { ...config }
|
? { ...config }
|
||||||
: buildExecutionWorkspaceAdapterConfig({
|
: buildExecutionWorkspaceAdapterConfig({
|
||||||
@@ -7980,7 +8007,31 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
"local agent jwt secret missing or invalid; running without injected PAPERCLIP_API_KEY",
|
"local agent jwt secret missing or invalid; running without injected PAPERCLIP_API_KEY",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const adapterResult = await adapter.execute({
|
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,
|
runId: run.id,
|
||||||
agent,
|
agent,
|
||||||
runtime: runtimeForAdapter,
|
runtime: runtimeForAdapter,
|
||||||
@@ -8005,6 +8056,31 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
},
|
},
|
||||||
authToken: authToken ?? undefined,
|
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),
|
||||||
|
});
|
||||||
|
} 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
|
const adapterManagedRuntimeServices = adapterResult.runtimeServices
|
||||||
? await persistAdapterManagedRuntimeServices({
|
? await persistAdapterManagedRuntimeServices({
|
||||||
db,
|
db,
|
||||||
@@ -8250,6 +8326,54 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
: livenessRun,
|
: livenessRun,
|
||||||
agent,
|
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) {
|
if (finalizedRun) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
suggestTasksResultSchema,
|
suggestTasksResultSchema,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||||
import { issueService } from "./issues.js";
|
import { issueService, listUnfinalizedExecutionWorkspaceIds } from "./issues.js";
|
||||||
|
|
||||||
type InteractionActor = {
|
type InteractionActor = {
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
@@ -457,6 +457,32 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
.then((rows) => rows[0] ?? null);
|
.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: {
|
async function getPendingInteractionForResolution(args: {
|
||||||
issue: { id: string; companyId: string };
|
issue: { id: string; companyId: string };
|
||||||
interactionId: string;
|
interactionId: string;
|
||||||
@@ -747,8 +773,12 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
||||||
switch (current.kind) {
|
switch (current.kind) {
|
||||||
case "suggest_tasks":
|
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);
|
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor);
|
||||||
case "request_confirmation": {
|
case "request_confirmation": {
|
||||||
|
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
|
||||||
const accepted = await acceptRequestConfirmation({
|
const accepted = await acceptRequestConfirmation({
|
||||||
issue,
|
issue,
|
||||||
current,
|
current,
|
||||||
|
|||||||
+114
-33
@@ -30,6 +30,7 @@ import {
|
|||||||
labels,
|
labels,
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
projects,
|
projects,
|
||||||
|
workspaceOperations,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import type {
|
import type {
|
||||||
AcceptedPlanDecomposition,
|
AcceptedPlanDecomposition,
|
||||||
@@ -351,6 +352,8 @@ export type IssueDependencyReadiness = {
|
|||||||
blockerIssueIds: string[];
|
blockerIssueIds: string[];
|
||||||
unresolvedBlockerIssueIds: string[];
|
unresolvedBlockerIssueIds: string[];
|
||||||
unresolvedBlockerCount: number;
|
unresolvedBlockerCount: number;
|
||||||
|
/** Blockers whose status is `done` but whose execution workspace has not yet finalized. */
|
||||||
|
pendingFinalizeBlockerIssueIds: string[];
|
||||||
allBlockersDone: boolean;
|
allBlockersDone: boolean;
|
||||||
isDependencyReady: boolean;
|
isDependencyReady: boolean;
|
||||||
};
|
};
|
||||||
@@ -582,11 +585,70 @@ function createIssueDependencyReadiness(issueId: string): IssueDependencyReadine
|
|||||||
blockerIssueIds: [],
|
blockerIssueIds: [],
|
||||||
unresolvedBlockerIssueIds: [],
|
unresolvedBlockerIssueIds: [],
|
||||||
unresolvedBlockerCount: 0,
|
unresolvedBlockerCount: 0,
|
||||||
|
pendingFinalizeBlockerIssueIds: [],
|
||||||
allBlockersDone: true,
|
allBlockersDone: true,
|
||||||
isDependencyReady: 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(
|
async function listIssueDependencyReadinessMap(
|
||||||
dbOrTx: Pick<Db, "select">,
|
dbOrTx: Pick<Db, "select">,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
@@ -604,6 +666,7 @@ async function listIssueDependencyReadinessMap(
|
|||||||
issueId: issueRelations.relatedIssueId,
|
issueId: issueRelations.relatedIssueId,
|
||||||
blockerIssueId: issueRelations.issueId,
|
blockerIssueId: issueRelations.issueId,
|
||||||
blockerStatus: issues.status,
|
blockerStatus: issues.status,
|
||||||
|
blockerExecutionWorkspaceId: issues.executionWorkspaceId,
|
||||||
})
|
})
|
||||||
.from(issueRelations)
|
.from(issueRelations)
|
||||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
.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) {
|
for (const row of blockerRows) {
|
||||||
const current = readinessMap.get(row.issueId) ?? createIssueDependencyReadiness(row.issueId);
|
const current = readinessMap.get(row.issueId) ?? createIssueDependencyReadiness(row.issueId);
|
||||||
current.blockerIssueIds.push(row.blockerIssueId);
|
current.blockerIssueIds.push(row.blockerIssueId);
|
||||||
@@ -625,6 +703,21 @@ async function listIssueDependencyReadinessMap(
|
|||||||
current.unresolvedBlockerCount += 1;
|
current.unresolvedBlockerCount += 1;
|
||||||
current.allBlockersDone = false;
|
current.allBlockersDone = false;
|
||||||
current.isDependencyReady = 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);
|
readinessMap.set(row.issueId, current);
|
||||||
}
|
}
|
||||||
@@ -4091,45 +4184,33 @@ export function issueService(db: Db) {
|
|||||||
);
|
);
|
||||||
if (candidates.length === 0) return [];
|
if (candidates.length === 0) return [];
|
||||||
|
|
||||||
const candidateIds = candidates.map((candidate) => candidate.id);
|
const wakeableCandidates = candidates.filter(
|
||||||
const blockerRows = await db
|
(candidate) =>
|
||||||
.select({
|
candidate.assigneeAgentId && !["backlog", "done", "cancelled"].includes(candidate.status),
|
||||||
issueId: issueRelations.relatedIssueId,
|
);
|
||||||
blockerIssueId: issueRelations.issueId,
|
if (wakeableCandidates.length === 0) return [];
|
||||||
blockerStatus: issues.status,
|
|
||||||
})
|
// Defer to the unified readiness check so that a dependent only fires when
|
||||||
.from(issueRelations)
|
// (a) every blocker is done AND (b) every done blocker's workspace has
|
||||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
// recorded a successful workspace_finalize. The finalize hook also calls
|
||||||
.where(
|
// this function on completion, so a wake initially gated by an in-flight
|
||||||
and(
|
// sync-back will re-fire once the restore lands locally.
|
||||||
eq(issueRelations.companyId, blockerIssue.companyId),
|
const readinessMap = await listIssueDependencyReadinessMap(
|
||||||
eq(issueRelations.type, "blocks"),
|
db,
|
||||||
inArray(issueRelations.relatedIssueId, candidateIds),
|
blockerIssue.companyId,
|
||||||
),
|
wakeableCandidates.map((candidate) => candidate.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const blockersByIssueId = new Map<string, Array<{ blockerIssueId: string; blockerStatus: string }>>();
|
return wakeableCandidates
|
||||||
for (const row of blockerRows) {
|
|
||||||
const list = blockersByIssueId.get(row.issueId) ?? [];
|
|
||||||
list.push({ blockerIssueId: row.blockerIssueId, blockerStatus: row.blockerStatus });
|
|
||||||
blockersByIssueId.set(row.issueId, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates
|
|
||||||
.filter((candidate) => candidate.assigneeAgentId && !["backlog", "done", "cancelled"].includes(candidate.status))
|
|
||||||
.map((candidate) => {
|
.map((candidate) => {
|
||||||
const blockers = blockersByIssueId.get(candidate.id) ?? [];
|
const readiness = readinessMap.get(candidate.id) ?? createIssueDependencyReadiness(candidate.id);
|
||||||
return {
|
return { candidate, readiness };
|
||||||
...candidate,
|
|
||||||
blockerIssueIds: blockers.map((blocker) => blocker.blockerIssueId),
|
|
||||||
allBlockersDone: blockers.length > 0 && blockers.every((blocker) => blocker.blockerStatus === "done"),
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
.filter((candidate) => candidate.allBlockersDone)
|
.filter(({ readiness }) => readiness.isDependencyReady && readiness.blockerIssueIds.length > 0)
|
||||||
.map((candidate) => ({
|
.map(({ candidate, readiness }) => ({
|
||||||
id: candidate.id,
|
id: candidate.id,
|
||||||
assigneeAgentId: candidate.assigneeAgentId!,
|
assigneeAgentId: candidate.assigneeAgentId!,
|
||||||
blockerIssueIds: candidate.blockerIssueIds,
|
blockerIssueIds: readiness.blockerIssueIds,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user