mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-10 08:30:39 +09:00
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:
parent
524e18b060
commit
1f70fd9a22
25 changed files with 21133 additions and 95 deletions
6
.github/workflows/pr.yml
vendored
6
.github/workflows/pr.yml
vendored
|
|
@ -45,6 +45,12 @@ jobs:
|
|||
- name: Validate Dockerfile deps stage
|
||||
run: node ./scripts/check-docker-deps-stage.mjs
|
||||
|
||||
- name: Reject git push in adapter/runtime code
|
||||
run: node ./scripts/check-no-git-push.mjs
|
||||
|
||||
- name: Test no-git-push check
|
||||
run: node --test ./scripts/check-no-git-push.test.mjs
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
|
|
|
|||
|
|
@ -249,6 +249,23 @@ Make Paperclip skills discoverable to your agent runtime without writing to the
|
|||
3. **Acceptable: env var** — point a skills path env var at the repo's `skills/` directory
|
||||
4. **Last resort: prompt injection** — include skill content in the prompt template
|
||||
|
||||
## Cross-run workspace persistence (no-remote-git contract)
|
||||
|
||||
The local execution-workspace cwd is the **only** persistence boundary across runs. No adapter may depend on a git remote for cross-run state.
|
||||
|
||||
The supported round-trip:
|
||||
|
||||
- **Per-run, on the remote side.** `prepareWorkspaceForSshExecution` (in `packages/adapter-utils/src/ssh.ts`) git-bundles the local worktree and ships it to the run's remote dir. No `git remote` is set anywhere; the bundle is the transport.
|
||||
- **End-of-run, in the adapter's `finally` block.** The adapter invokes `restoreRemoteWorkspace` (e.g. claude-local's `execute.ts`), which calls `restoreWorkspaceFromSshExecution` → `exportGitWorkspaceFromSsh` → `integrateImportedGitHead`. Remote commits made during the run land back in the local Mac worktree with no `git push` and no remote configured.
|
||||
|
||||
The invariant adapters must preserve:
|
||||
|
||||
- **Never `git push`** from adapter or runtime code. Operator-supplied configuration may opt in, but the default contract is no remote operations.
|
||||
- **Never assume a remote exists.** The local cwd is the source of truth between runs.
|
||||
- **Surface restore failures.** A failed sync-back must propagate as a run-level error, not a silent warning. The heartbeat records a `workspace_finalize` row (`succeeded`/`failed`) around `adapter.execute` so dependent issues do not wake on a stale worktree.
|
||||
|
||||
The invariant is pinned by the "no-remote-git contract" case in `packages/adapter-utils/src/ssh-fixture.test.ts`: it asserts `git remote` is empty before and after the round-trip and that a remote-only commit still lands locally via restore alone.
|
||||
|
||||
## Security
|
||||
|
||||
- Treat agent output as untrusted (parse defensively, never execute)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,17 @@ Heartbeat still resolves a workspace for the run, but that is about code locatio
|
|||
4. Heartbeat passes the resolved code workspace to the agent run.
|
||||
5. Workspace runtime services remain manual UI-managed controls rather than automatic heartbeat-managed services.
|
||||
|
||||
## Cross-run persistence (no-remote-git contract)
|
||||
|
||||
Code state moves between runs through the local execution-workspace cwd alone — not through a git remote.
|
||||
|
||||
- Each run's prepare step bundles the local worktree to the run's remote dir over ssh, with no `git remote` configured.
|
||||
- The adapter's restore step at the end of the run writes any new remote commits back into the local worktree directly.
|
||||
- Adapters must never `git push` from runtime code, and must never assume a remote exists.
|
||||
- A failed restore is a run-level error and records `workspace_finalize=failed` on the execution workspace, which gates dependent issue wakes until the next successful finalize.
|
||||
|
||||
The invariant is enforced by the "no-remote-git contract" case in `packages/adapter-utils/src/ssh-fixture.test.ts`, which asserts a remote-only commit reaches the local worktree with no remote configured at any point.
|
||||
|
||||
## Current implementation guarantees
|
||||
|
||||
With the current implementation:
|
||||
|
|
|
|||
|
|
@ -35,12 +35,14 @@
|
|||
"release:rollback": "./scripts/rollback-latest.sh",
|
||||
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
|
||||
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
|
||||
"check:no-git-push": "node scripts/check-no-git-push.mjs",
|
||||
"test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs",
|
||||
"docs:dev": "cd docs && npx mintlify dev",
|
||||
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
|
||||
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
|
||||
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
|
||||
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
|
||||
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs",
|
||||
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs",
|
||||
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
|
||||
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
|
||||
"test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",
|
||||
|
|
|
|||
37
packages/adapter-utils/README.md
Normal file
37
packages/adapter-utils/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# @paperclipai/adapter-utils
|
||||
|
||||
Shared utilities for Paperclip adapters: process spawning, environment
|
||||
injection, sandbox/SSH transport, workspace sync, and the round-trip helpers
|
||||
that move code between the local execution-workspace cwd and wherever the
|
||||
agent actually runs.
|
||||
|
||||
For the adapter-author guide see
|
||||
[`docs/adapters/creating-an-adapter.md`](../../docs/adapters/creating-an-adapter.md)
|
||||
and the in-repo notes at [`packages/adapters/AUTHORING.md`](../adapters/AUTHORING.md).
|
||||
|
||||
## No-remote-git contract
|
||||
|
||||
The local execution-workspace cwd is the only persistence boundary across
|
||||
runs. No adapter may depend on a git remote for cross-run state.
|
||||
|
||||
Adapters that run the agent on a different host should use the SSH round-trip
|
||||
helpers in [`src/ssh.ts`](./src/ssh.ts):
|
||||
|
||||
- `prepareWorkspaceForSshExecution({ spec, localDir, remoteDir })` — bundles
|
||||
the local cwd (tracked files, dirty edits, untracked additions, and the git
|
||||
history needed to reconstruct it) to `remoteDir` before the run starts. Runs
|
||||
with no `git remote` configured.
|
||||
- `restoreWorkspaceFromSshExecution({ spec, localDir, remoteDir, ... })` —
|
||||
syncs the remote cwd back into `localDir` after the run, including any new
|
||||
commits the agent created. Also runs with no `git remote` configured.
|
||||
|
||||
`prepareRemoteManagedRuntime` in
|
||||
[`src/remote-managed-runtime.ts`](./src/remote-managed-runtime.ts) wraps both
|
||||
calls for adapters that want a per-run remote workspace and an automatic
|
||||
`restoreWorkspace()` finally hook.
|
||||
|
||||
The invariant is pinned by the `no-remote-git contract` case in
|
||||
[`src/ssh-fixture.test.ts`](./src/ssh-fixture.test.ts), which asserts that a
|
||||
remote-only commit propagates to the local worktree through the
|
||||
prepare → restore round-trip with no git remote configured at any point. Do
|
||||
not regress that test.
|
||||
|
|
@ -451,6 +451,68 @@ describe("ssh env-lab fixture", () => {
|
|||
await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n");
|
||||
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||
|
||||
it("propagates remote commits to the local worktree with no git remote configured (no-remote-git contract)", async () => {
|
||||
// Locks in the architectural contract documented in
|
||||
// packages/adapter-utils/README.md and packages/adapters/AUTHORING.md:
|
||||
// the local execution-workspace cwd is the only persistence boundary
|
||||
// across runs. No adapter may depend on a git remote for cross-run state.
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const statePath = path.join(rootDir, "state.json");
|
||||
const localRepo = path.join(rootDir, "local-workspace");
|
||||
|
||||
await mkdir(localRepo, { recursive: true });
|
||||
await git(localRepo, ["init"]);
|
||||
await git(localRepo, ["checkout", "-b", "main"]);
|
||||
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
|
||||
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
|
||||
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
|
||||
await git(localRepo, ["add", "tracked.txt"]);
|
||||
await git(localRepo, ["commit", "-m", "initial"]);
|
||||
|
||||
// Assert there is no git remote configured before we begin, and verify
|
||||
// that no point in the round-trip introduces one. `git remote` returns an
|
||||
// empty string when no remotes exist (and exit code 0).
|
||||
expect(await git(localRepo, ["remote"])).toBe("");
|
||||
|
||||
const started = await startSshEnvLabFixtureOrSkip(
|
||||
statePath,
|
||||
"no-remote-git contract test",
|
||||
);
|
||||
if (!started) return;
|
||||
const config = await buildSshEnvLabFixtureConfig(started);
|
||||
const spec = {
|
||||
...config,
|
||||
remoteCwd: started.workspaceDir,
|
||||
} as const;
|
||||
|
||||
const prepared = await prepareRemoteManagedRuntime({
|
||||
spec,
|
||||
runId: "run-no-remote",
|
||||
adapterKey: "test-adapter",
|
||||
workspaceLocalDir: localRepo,
|
||||
});
|
||||
|
||||
// Remote commit lands a deliverable that must show up locally via
|
||||
// sync-back alone — no `git push`, no fetch from any origin.
|
||||
await runSshCommand(
|
||||
config,
|
||||
`cd ${JSON.stringify(prepared.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "deliverable\\n" > tracked.txt && git add tracked.txt && git commit -m "remote-only commit" >/dev/null`,
|
||||
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
||||
);
|
||||
|
||||
await prepared.restoreWorkspace();
|
||||
|
||||
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe(
|
||||
"remote-only commit",
|
||||
);
|
||||
expect(await readFile(path.join(localRepo, "tracked.txt"), "utf8")).toBe(
|
||||
"deliverable\n",
|
||||
);
|
||||
// Final assertion: still no git remote — restore did not silently add one.
|
||||
expect(await git(localRepo, ["remote"])).toBe("");
|
||||
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||
|
||||
it("merges concurrent remote commits through the managed runtime restore path", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
|
|
|||
58
packages/adapters/AUTHORING.md
Normal file
58
packages/adapters/AUTHORING.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Adapter Authoring Notes
|
||||
|
||||
In-repo notes for adapter authors. The user-facing guide lives at
|
||||
[`docs/adapters/creating-an-adapter.md`](../../docs/adapters/creating-an-adapter.md);
|
||||
this file holds invariants that are easy to violate from inside the adapter
|
||||
package itself.
|
||||
|
||||
## No-remote-git contract (cross-run persistence)
|
||||
|
||||
The local execution-workspace cwd is the only persistence boundary across
|
||||
runs. No adapter may depend on a git remote for cross-run state.
|
||||
|
||||
Why: Paperclip resolves a local execution workspace (a worktree) for each
|
||||
heartbeat. Code state is carried forward by syncing that local cwd to wherever
|
||||
the agent actually runs — over ssh, into a sandbox, into a managed runtime —
|
||||
and then syncing changes back when the run finishes. Treating a `git remote`
|
||||
as the source of truth (`git push` from inside the agent, fetch on the next
|
||||
wake) breaks dependent issues that are gated on the local worktree being
|
||||
caught up, and breaks isolated execution workspaces that have no remote
|
||||
configured at all.
|
||||
|
||||
How to apply:
|
||||
|
||||
- Never `git push` from adapter runtime code. Never assume the local worktree
|
||||
has any `git remote` configured. If you need data from the previous run,
|
||||
read it from the local cwd Paperclip handed you.
|
||||
- If your adapter runs the agent on a different host (ssh, sandbox, remote
|
||||
container), use the round-trip helpers in `@paperclipai/adapter-utils`:
|
||||
[`prepareWorkspaceForSshExecution`](../adapter-utils/src/ssh.ts) bundles the
|
||||
local cwd to the remote dir before the run, and
|
||||
[`restoreWorkspaceFromSshExecution`](../adapter-utils/src/ssh.ts) syncs
|
||||
remote-side changes (including new git commits) back into the local cwd
|
||||
after the run. Both run with no `git remote` configured.
|
||||
- If your adapter runs the agent locally, you can read and write the cwd
|
||||
directly — same invariant applies: changes that future runs need must live
|
||||
in the local cwd by the time `execute()` returns.
|
||||
- A failed sync-back is a run-level error. The heartbeat records
|
||||
`workspace_finalize=failed` on the execution workspace, which gates
|
||||
dependent issue wakes until the next successful finalize. Do not swallow
|
||||
restore errors.
|
||||
|
||||
The invariant is pinned by the `no-remote-git contract` case in
|
||||
[`packages/adapter-utils/src/ssh-fixture.test.ts`](../adapter-utils/src/ssh-fixture.test.ts),
|
||||
which asserts that a remote-only commit propagates to the local worktree
|
||||
through `prepareWorkspaceForSshExecution` → `restoreWorkspaceFromSshExecution`
|
||||
with no git remote configured at any point.
|
||||
|
||||
A static check enforces the rule before runtime ever sees it:
|
||||
[`scripts/check-no-git-push.mjs`](../../scripts/check-no-git-push.mjs) scans
|
||||
adapter and runtime source (`packages/adapters/`, `packages/adapter-utils/`,
|
||||
`server/src/`, `cli/src/`) and fails the `policy` CI job if any unapproved
|
||||
`git push` invocation is added. If you are building an operator-configured
|
||||
path that legitimately must push, add a
|
||||
`// paperclip:allow-git-push: <reason>` comment on the line (or the line
|
||||
above) so the opt-in shows up in code review.
|
||||
|
||||
For the architecture-level write-up of cross-run persistence, see
|
||||
[`docs/guides/board-operator/execution-workspaces-and-runtime-services.md`](../../docs/guides/board-operator/execution-workspaces-and-runtime-services.md#cross-run-persistence-no-remote-git-contract).
|
||||
|
|
@ -70,3 +70,16 @@ Structured gateway event logs use:
|
|||
- `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames
|
||||
|
||||
UI/CLI parsers consume these lines to render transcript updates.
|
||||
|
||||
## No-remote-git contract
|
||||
|
||||
Like every Paperclip adapter, this one must treat the local execution-workspace
|
||||
cwd as the only persistence boundary across runs — no `git push` from runtime
|
||||
code, no assuming a `git remote` exists. The gateway transport here doesn't
|
||||
touch the workspace directly, but if you extend the adapter to ship code to
|
||||
the OpenClaw side, use the round-trip helpers in `@paperclipai/adapter-utils`
|
||||
(`prepareWorkspaceForSshExecution` → `restoreWorkspaceFromSshExecution`)
|
||||
rather than reaching for a git remote. See
|
||||
[`packages/adapters/AUTHORING.md`](../AUTHORING.md#no-remote-git-contract-cross-run-persistence)
|
||||
for the full contract and the pinning test at
|
||||
[`packages/adapter-utils/src/ssh-fixture.test.ts`](../../adapter-utils/src/ssh-fixture.test.ts).
|
||||
|
|
|
|||
6
packages/db/src/migrations/0093_giant_green_goblin.sql
Normal file
6
packages/db/src/migrations/0093_giant_green_goblin.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
ALTER TABLE "execution_workspaces" DROP CONSTRAINT "execution_workspaces_company_id_companies_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace_operations" DROP CONSTRAINT "workspace_operations_company_id_companies_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
19543
packages/db/src/migrations/meta/0093_snapshot.json
Normal file
19543
packages/db/src/migrations/meta/0093_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -652,6 +652,13 @@
|
|||
"when": 1779999768200,
|
||||
"tag": "0092_mighty_puma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 93,
|
||||
"version": "7",
|
||||
"when": 1780040470886,
|
||||
"tag": "0093_giant_green_goblin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ export const executionWorkspaces = pgTable(
|
|||
"execution_workspaces",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
||||
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
|
||||
sourceIssueId: uuid("source_issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const workspaceOperations = pgTable(
|
|||
"workspace_operations",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
executionWorkspaceId: uuid("execution_workspace_id").references(() => executionWorkspaces.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ export type WorkspaceOperationPhase =
|
|||
| "worktree_prepare"
|
||||
| "workspace_provision"
|
||||
| "workspace_teardown"
|
||||
| "worktree_cleanup";
|
||||
| "worktree_cleanup"
|
||||
| "workspace_finalize";
|
||||
|
||||
export type WorkspaceOperationStatus = "running" | "succeeded" | "failed" | "skipped";
|
||||
|
||||
|
|
|
|||
196
scripts/check-no-git-push.mjs
Normal file
196
scripts/check-no-git-push.mjs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-no-git-push.mjs
|
||||
*
|
||||
* Static check that rejects `git push` (and equivalent remote-mutating git
|
||||
* invocations) inside adapter/runtime source code.
|
||||
*
|
||||
* Adapter and runtime code may never push to a git remote: the local
|
||||
* execution-workspace cwd is the only persistence boundary between runs
|
||||
* (see packages/adapters/AUTHORING.md and PAPA-432). Release tooling and
|
||||
* developer scripts that legitimately push are out of scope because they
|
||||
* live outside the directories scanned here.
|
||||
*
|
||||
* Opt-in mechanism: a line containing `paperclip:allow-git-push` (typically
|
||||
* inside a `// paperclip:allow-git-push: <reason>` comment on the line itself
|
||||
* or the line immediately above) suppresses the match. This is reserved for
|
||||
* operator-configured paths that legitimately push and must be reviewed.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const DEFAULT_SCAN_ROOTS = [
|
||||
"packages/adapters",
|
||||
"packages/adapter-utils",
|
||||
"server/src",
|
||||
"cli/src",
|
||||
];
|
||||
|
||||
const SCANNABLE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs"]);
|
||||
|
||||
const SKIP_DIRECTORY_NAMES = new Set([
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
".turbo",
|
||||
".next",
|
||||
"coverage",
|
||||
]);
|
||||
|
||||
const SKIP_FILENAME_SUFFIXES = [".d.ts"];
|
||||
|
||||
// Matches actual git push invocations in either:
|
||||
// `git push ...` (shell command string)
|
||||
// ["git", "push", ...] (args-array form for execSync)
|
||||
// execFile("git", ["push", ...]) / spawn("git", ["push", ...])
|
||||
export const GIT_PUSH_PATTERNS = [
|
||||
/\bgit[\s_-]+push\b/i,
|
||||
/["'`]git["'`]\s*,\s*\[?\s*["'`]push["'`]/i,
|
||||
];
|
||||
// Kept for backwards-compatibility with existing tests/importers.
|
||||
export const GIT_PUSH_PATTERN = GIT_PUSH_PATTERNS[0];
|
||||
export const ALLOW_MARKER = "paperclip:allow-git-push";
|
||||
|
||||
function lineMatchesGitPush(line) {
|
||||
return GIT_PUSH_PATTERNS.some((pattern) => pattern.test(line));
|
||||
}
|
||||
|
||||
function stripLineComment(line) {
|
||||
// Strip everything from the first `//` that is not inside a string literal.
|
||||
// This is a lightweight heuristic: we only need to remove obvious doc-style
|
||||
// mentions of "git push" so they do not trip the check. The check still
|
||||
// flags any match that survives comment stripping.
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let inBacktick = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
// A character is escaped only if it's preceded by an odd number of
|
||||
// backslashes; e.g. `"foo\\"` ends a string because the trailing `\\`
|
||||
// is a single escaped backslash, leaving the closing `"` unescaped.
|
||||
let backslashes = 0;
|
||||
for (let scan = index - 1; scan >= 0 && line[scan] === "\\"; scan -= 1) {
|
||||
backslashes += 1;
|
||||
}
|
||||
const isEscaped = backslashes % 2 === 1;
|
||||
|
||||
if (!inDouble && !inBacktick && char === "'" && !isEscaped) inSingle = !inSingle;
|
||||
else if (!inSingle && !inBacktick && char === '"' && !isEscaped) inDouble = !inDouble;
|
||||
else if (!inSingle && !inDouble && char === "`" && !isEscaped) inBacktick = !inBacktick;
|
||||
else if (!inSingle && !inDouble && !inBacktick && char === "/" && line[index + 1] === "/") {
|
||||
return line.slice(0, index);
|
||||
}
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
export function findGitPushOffenses(text) {
|
||||
const lines = text.split("\n");
|
||||
const offenses = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const stripped = stripLineComment(line);
|
||||
if (!lineMatchesGitPush(stripped)) continue;
|
||||
|
||||
const previousLine = index > 0 ? lines[index - 1] : "";
|
||||
const isAllowed = line.includes(ALLOW_MARKER) || previousLine.includes(ALLOW_MARKER);
|
||||
if (isAllowed) continue;
|
||||
|
||||
offenses.push({ lineNumber: index + 1, line: line.trimEnd() });
|
||||
}
|
||||
|
||||
return offenses;
|
||||
}
|
||||
|
||||
function shouldScanFile(relativePath) {
|
||||
if (SKIP_FILENAME_SUFFIXES.some((suffix) => relativePath.endsWith(suffix))) return false;
|
||||
const extension = path.extname(relativePath);
|
||||
return SCANNABLE_EXTENSIONS.has(extension);
|
||||
}
|
||||
|
||||
export function collectScannableFiles(absoluteRoot, repoRoot) {
|
||||
const results = [];
|
||||
let stats;
|
||||
try {
|
||||
stats = statSync(absoluteRoot);
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
if (!stats.isDirectory()) return results;
|
||||
|
||||
const stack = [absoluteRoot];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRECTORY_NAMES.has(entry.name)) continue;
|
||||
stack.push(path.join(current, entry.name));
|
||||
continue;
|
||||
}
|
||||
const absolute = path.join(current, entry.name);
|
||||
const relative = path.relative(repoRoot, absolute).split(path.sep).join("/");
|
||||
if (shouldScanFile(relative)) results.push({ absolute, relative });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function runCheck({ repoRoot, scanRoots = DEFAULT_SCAN_ROOTS, log = console.log, error = console.error } = {}) {
|
||||
const allOffenses = [];
|
||||
|
||||
for (const scanRoot of scanRoots) {
|
||||
const absoluteRoot = path.resolve(repoRoot, scanRoot);
|
||||
const files = collectScannableFiles(absoluteRoot, repoRoot);
|
||||
for (const file of files) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(file.absolute, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const offenses = findGitPushOffenses(text);
|
||||
for (const offense of offenses) {
|
||||
allOffenses.push({ relative: file.relative, ...offense });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allOffenses.length > 0) {
|
||||
error("ERROR: `git push` (or equivalent remote-mutating git command) found in adapter/runtime code:\n");
|
||||
for (const offense of allOffenses) {
|
||||
error(` ${offense.relative}:${offense.lineNumber}: ${offense.line}`);
|
||||
}
|
||||
error(
|
||||
"\nAdapter and runtime code must not push to a git remote. The local execution-workspace cwd is the only persistence boundary between runs (see packages/adapters/AUTHORING.md and PAPA-432).",
|
||||
);
|
||||
error(
|
||||
`If the operator has explicitly configured a path that must push, add a \`${ALLOW_MARKER}: <reason>\` comment on the matching line or the line immediately above to opt in.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
log(` ✓ No unapproved \`git push\` invocations found in adapter/runtime code.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const repoRoot = process.cwd();
|
||||
process.exit(runCheck({ repoRoot }));
|
||||
}
|
||||
170
scripts/check-no-git-push.test.mjs
Normal file
170
scripts/check-no-git-push.test.mjs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
ALLOW_MARKER,
|
||||
GIT_PUSH_PATTERN,
|
||||
collectScannableFiles,
|
||||
findGitPushOffenses,
|
||||
runCheck,
|
||||
} from "./check-no-git-push.mjs";
|
||||
|
||||
test("regex matches common git push forms", () => {
|
||||
assert.ok(GIT_PUSH_PATTERN.test("git push"));
|
||||
assert.ok(GIT_PUSH_PATTERN.test("GIT PUSH"));
|
||||
assert.ok(GIT_PUSH_PATTERN.test("git push origin master"));
|
||||
assert.ok(GIT_PUSH_PATTERN.test("git-push"));
|
||||
assert.ok(GIT_PUSH_PATTERN.test("git_push"));
|
||||
});
|
||||
|
||||
test("regex ignores unrelated `push` usages", () => {
|
||||
assert.ok(!GIT_PUSH_PATTERN.test("args.push('git')"));
|
||||
assert.ok(!GIT_PUSH_PATTERN.test("notes.push('git remote')"));
|
||||
assert.ok(!GIT_PUSH_PATTERN.test("pushed"));
|
||||
assert.ok(!GIT_PUSH_PATTERN.test("git fetch"));
|
||||
});
|
||||
|
||||
test("findGitPushOffenses flags a bare invocation in a string", () => {
|
||||
const text = `await exec("git push origin master");\n`;
|
||||
const offenses = findGitPushOffenses(text);
|
||||
assert.equal(offenses.length, 1);
|
||||
assert.equal(offenses[0].lineNumber, 1);
|
||||
});
|
||||
|
||||
test("findGitPushOffenses ignores mentions inside `//` comments", () => {
|
||||
const text = `// sync-back alone — no \`git push\`, no fetch from any origin.\nconst x = 1;\n`;
|
||||
assert.deepEqual(findGitPushOffenses(text), []);
|
||||
});
|
||||
|
||||
test("findGitPushOffenses allows opt-in marker on the same line", () => {
|
||||
const text = `await exec("git push origin master"); // ${ALLOW_MARKER}: operator-configured release mirror\n`;
|
||||
assert.deepEqual(findGitPushOffenses(text), []);
|
||||
});
|
||||
|
||||
test("findGitPushOffenses allows opt-in marker on the line above", () => {
|
||||
const text = `// ${ALLOW_MARKER}: operator-configured release mirror\nawait exec("git push origin master");\n`;
|
||||
assert.deepEqual(findGitPushOffenses(text), []);
|
||||
});
|
||||
|
||||
test("findGitPushOffenses flags string-literal push even when text is split across mixed quotes", () => {
|
||||
const text = "const cmd = `git push --tags`;\n";
|
||||
const offenses = findGitPushOffenses(text);
|
||||
assert.equal(offenses.length, 1);
|
||||
});
|
||||
|
||||
test("findGitPushOffenses flags args-array form passed to spawn/execFile", () => {
|
||||
const cases = [
|
||||
`spawn("git", ["push", "origin", "main"]);\n`,
|
||||
`execFile('git', ['push', '--tags']);\n`,
|
||||
"execFile(`git`, [`push`, `--mirror`]);\n",
|
||||
];
|
||||
for (const text of cases) {
|
||||
const offenses = findGitPushOffenses(text);
|
||||
assert.equal(offenses.length, 1, `expected match for ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("findGitPushOffenses ignores `git push` in a comment after a string ending with a literal backslash", () => {
|
||||
// The closing `"` after `\\` should end the string (even literal count of
|
||||
// backslashes leaves the quote unescaped), so the `// git push` that
|
||||
// follows is comment text and must be stripped.
|
||||
const text = 'const path = "C:\\\\"; // git push origin master\nconst y = 2;\n';
|
||||
assert.deepEqual(findGitPushOffenses(text), []);
|
||||
});
|
||||
|
||||
test("findGitPushOffenses does not flag args-array form when allow marker is present", () => {
|
||||
const text = `// ${ALLOW_MARKER}: release tooling adapter\nspawn("git", ["push", "origin", "main"]);\n`;
|
||||
assert.deepEqual(findGitPushOffenses(text), []);
|
||||
});
|
||||
|
||||
test("runCheck passes when scoped tree has no offenses", () => {
|
||||
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "no-git-push-pass-"));
|
||||
try {
|
||||
mkdirSync(path.join(tmpRoot, "packages/adapters/sample/src"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(tmpRoot, "packages/adapters/sample/src/index.ts"),
|
||||
"export const ok = 1;\n",
|
||||
);
|
||||
const logs = [];
|
||||
const errors = [];
|
||||
const code = runCheck({
|
||||
repoRoot: tmpRoot,
|
||||
scanRoots: ["packages/adapters"],
|
||||
log: (msg) => logs.push(msg),
|
||||
error: (msg) => errors.push(msg),
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.equal(errors.length, 0);
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("runCheck fails when scoped tree contains an unapproved git push", () => {
|
||||
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "no-git-push-fail-"));
|
||||
try {
|
||||
mkdirSync(path.join(tmpRoot, "packages/adapters/sample/src"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(tmpRoot, "packages/adapters/sample/src/index.ts"),
|
||||
"import { execSync } from 'node:child_process';\nexecSync('git push origin main');\n",
|
||||
);
|
||||
const logs = [];
|
||||
const errors = [];
|
||||
const code = runCheck({
|
||||
repoRoot: tmpRoot,
|
||||
scanRoots: ["packages/adapters"],
|
||||
log: (msg) => logs.push(msg),
|
||||
error: (msg) => errors.push(msg),
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.ok(errors.some((line) => line.includes("packages/adapters/sample/src/index.ts:2")));
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("runCheck ignores opt-in marker outside the scoped tree", () => {
|
||||
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "no-git-push-scope-"));
|
||||
try {
|
||||
mkdirSync(path.join(tmpRoot, "scripts"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(tmpRoot, "scripts/release.mjs"),
|
||||
"execSync('git push origin v1.2.3');\n",
|
||||
);
|
||||
const code = runCheck({
|
||||
repoRoot: tmpRoot,
|
||||
scanRoots: ["packages/adapters", "server/src"],
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("collectScannableFiles skips node_modules, dist, and .d.ts", () => {
|
||||
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "no-git-push-collect-"));
|
||||
try {
|
||||
const adaptersRoot = path.join(tmpRoot, "packages/adapters/sample");
|
||||
mkdirSync(path.join(adaptersRoot, "src"), { recursive: true });
|
||||
mkdirSync(path.join(adaptersRoot, "dist"), { recursive: true });
|
||||
mkdirSync(path.join(adaptersRoot, "node_modules/pkg"), { recursive: true });
|
||||
writeFileSync(path.join(adaptersRoot, "src/index.ts"), "");
|
||||
writeFileSync(path.join(adaptersRoot, "src/types.d.ts"), "");
|
||||
writeFileSync(path.join(adaptersRoot, "dist/index.js"), "");
|
||||
writeFileSync(path.join(adaptersRoot, "node_modules/pkg/index.js"), "");
|
||||
|
||||
const files = collectScannableFiles(
|
||||
path.join(tmpRoot, "packages/adapters"),
|
||||
tmpRoot,
|
||||
);
|
||||
const relatives = files.map((entry) => entry.relative).sort();
|
||||
assert.deepEqual(relatives, ["packages/adapters/sample/src/index.ts"]);
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -148,16 +148,117 @@ describe("execution workspace policy helpers", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("prefers persisted environment selection over issue and project defaults", () => {
|
||||
it("reuses persisted workspace environment when it agrees with the assignee's identity", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: "agent-env" },
|
||||
issueSettings: { environmentId: "agent-env" },
|
||||
workspaceConfig: { environmentId: "agent-env" },
|
||||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toEqual({
|
||||
environmentId: "agent-env",
|
||||
source: "workspace",
|
||||
conflict: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses silent reuse when the persisted workspace env disagrees with the assignee (PAPA-380: sandbox agent on local workspace)", () => {
|
||||
// Claude E2B was assigned to a child issue whose parent had already
|
||||
// realized a `Local` workspace. The persisted workspace env must not
|
||||
// shadow the agent's intended sandbox env.
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: null },
|
||||
issueSettings: { environmentId: "sandbox-env", mode: "shared_workspace" },
|
||||
workspaceConfig: { environmentId: "local-env" },
|
||||
agentDefaultEnvironmentId: "sandbox-env",
|
||||
defaultEnvironmentId: "local-env",
|
||||
}),
|
||||
).toEqual({
|
||||
environmentId: "sandbox-env",
|
||||
source: "issue",
|
||||
conflict: {
|
||||
reason: "reused_workspace_environment_mismatch",
|
||||
workspaceEnvironmentId: "local-env",
|
||||
assigneeIntendedEnvironmentId: "sandbox-env",
|
||||
assigneeIntendedSource: "issue",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses silent reuse when a null-default (local) agent inherits a non-local workspace env (PAPA-431: Manual QA on engineer SSH workspace)", () => {
|
||||
// Manual QA agent has defaultEnvironmentId: null. When a sibling issue's
|
||||
// SSH workspace is inherited via inheritExecutionWorkspaceFromIssueId,
|
||||
// the persisted SSH env must NOT shadow the agent's deliberate local
|
||||
// identity. The inherited issueSettings.environmentId is treated as a
|
||||
// promoted artifact, not an explicit operator choice.
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: null },
|
||||
issueSettings: { environmentId: "ssh-env", mode: "isolated_workspace" },
|
||||
workspaceConfig: { environmentId: "ssh-env" },
|
||||
agentDefaultEnvironmentId: null,
|
||||
defaultEnvironmentId: "local-env",
|
||||
}),
|
||||
).toEqual({
|
||||
environmentId: "local-env",
|
||||
source: "default",
|
||||
conflict: {
|
||||
reason: "reused_workspace_environment_mismatch",
|
||||
workspaceEnvironmentId: "ssh-env",
|
||||
assigneeIntendedEnvironmentId: "local-env",
|
||||
assigneeIntendedSource: "default",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("honors an explicit issue env override for null-default agents when no workspace is being reused", () => {
|
||||
// Operator explicitly chose an env on this issue via PATCH (see the
|
||||
// issues-service contract at issues-service.test.ts:1924). For null-default
|
||||
// agents, this is a deliberate choice — only inherited issue env (which
|
||||
// matches a reused workspace env) should be discarded.
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: "project-env" },
|
||||
issueSettings: { environmentId: "issue-env" },
|
||||
workspaceConfig: { environmentId: "workspace-env" },
|
||||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
workspaceConfig: null,
|
||||
agentDefaultEnvironmentId: null,
|
||||
defaultEnvironmentId: "local-env",
|
||||
}),
|
||||
).toBe("workspace-env");
|
||||
).toEqual({
|
||||
environmentId: "issue-env",
|
||||
source: "issue",
|
||||
conflict: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors an explicit issue env override for null-default agents even against a disagreeing reused workspace", () => {
|
||||
// Operator picked sandbox-env explicitly while the previously-realized
|
||||
// workspace was on local-env. The mismatch is genuine — surface a conflict
|
||||
// so the heartbeat forces a fresh realization on the operator's chosen env.
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: null },
|
||||
issueSettings: { environmentId: "sandbox-env", mode: "shared_workspace" },
|
||||
workspaceConfig: { environmentId: "local-env" },
|
||||
agentDefaultEnvironmentId: null,
|
||||
defaultEnvironmentId: "local-env",
|
||||
}),
|
||||
).toEqual({
|
||||
environmentId: "sandbox-env",
|
||||
source: "issue",
|
||||
conflict: {
|
||||
reason: "reused_workspace_environment_mismatch",
|
||||
workspaceEnvironmentId: "local-env",
|
||||
assigneeIntendedEnvironmentId: "sandbox-env",
|
||||
assigneeIntendedSource: "issue",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the explicit issue environment over project and agent defaults when no workspace is reused", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: "project-env" },
|
||||
|
|
@ -166,7 +267,11 @@ describe("execution workspace policy helpers", () => {
|
|||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("issue-env");
|
||||
).toEqual({
|
||||
environmentId: "issue-env",
|
||||
source: "issue",
|
||||
conflict: null,
|
||||
});
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: "project-env" },
|
||||
|
|
@ -175,7 +280,11 @@ describe("execution workspace policy helpers", () => {
|
|||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("project-env");
|
||||
).toEqual({
|
||||
environmentId: "project-env",
|
||||
source: "project",
|
||||
conflict: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the agent default environment before the company default", () => {
|
||||
|
|
@ -187,7 +296,11 @@ describe("execution workspace policy helpers", () => {
|
|||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("agent-env");
|
||||
).toEqual({
|
||||
environmentId: "agent-env",
|
||||
source: "agent",
|
||||
conflict: null,
|
||||
});
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: null },
|
||||
|
|
@ -196,7 +309,11 @@ describe("execution workspace policy helpers", () => {
|
|||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("default-env");
|
||||
).toEqual({
|
||||
environmentId: "default-env",
|
||||
source: "project",
|
||||
conflict: null,
|
||||
});
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: null,
|
||||
|
|
@ -205,7 +322,11 @@ describe("execution workspace policy helpers", () => {
|
|||
agentDefaultEnvironmentId: null,
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("default-env");
|
||||
).toEqual({
|
||||
environmentId: "default-env",
|
||||
source: "default",
|
||||
conflict: null,
|
||||
});
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: null },
|
||||
|
|
@ -214,7 +335,11 @@ describe("execution workspace policy helpers", () => {
|
|||
agentDefaultEnvironmentId: null,
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("default-env");
|
||||
).toEqual({
|
||||
environmentId: "default-env",
|
||||
source: "default",
|
||||
conflict: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps persisted execution workspace modes back to issue settings", () => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
documents,
|
||||
environmentLeases,
|
||||
environments,
|
||||
executionWorkspaces,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
issueRelations,
|
||||
issueTreeHolds,
|
||||
issues,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -142,6 +144,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
await db.delete(agents);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(environments);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
documents,
|
||||
environmentLeases,
|
||||
environments,
|
||||
executionWorkspaces,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
|
|
@ -31,6 +32,7 @@ import {
|
|||
issueTreeHolds,
|
||||
issueWorkProducts,
|
||||
issues,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -378,6 +380,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
}
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await db.delete(companySkills);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(issuePlanDecompositions);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(documentAnnotationComments);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
|
|
@ -15,6 +16,9 @@ import {
|
|||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -48,7 +52,11 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
await db.delete(documents);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(goals);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
|
|
@ -1135,4 +1143,262 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace_finalize accept gate", () => {
|
||||
async function seedAcceptGateFixture() {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Workspace",
|
||||
sourceType: "local_path",
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "exec",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
});
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Accept gate fixture",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
goalId,
|
||||
title: "Issue with execution workspace",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
});
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Mark this issue done?",
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
return { companyId, projectId, executionWorkspaceId, issueId, goalId, interactionId: created.id };
|
||||
}
|
||||
|
||||
it("refuses accept when the issue's latest workspace operation is not a successful workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
// A run touched the workspace (prepare) but never recorded workspace_finalize.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("refuses accept when the latest workspace operation is a failed workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("allows accept once a successful workspace_finalize lands as the latest operation", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows accept of suggest_tasks even when no successful workspace_finalize has landed", async () => {
|
||||
// suggest_tasks acceptance only creates follow-up issues; it does not
|
||||
// approve code state or move the source workspace forward, so the
|
||||
// workspace_finalize gate (PAPA-440) must not apply here. Without this
|
||||
// carve-out the board cannot triage suggested tasks on an issue whose
|
||||
// latest workspace op is still worktree_prepare.
|
||||
const { companyId, executionWorkspaceId, issueId, goalId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-28T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "suggest_tasks",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
tasks: [
|
||||
{
|
||||
clientKey: "follow-up",
|
||||
title: "Created from suggest_tasks accept under prepare-only workspace",
|
||||
},
|
||||
],
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
created.id,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: created.id,
|
||||
kind: "suggest_tasks",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows accept when the issue has no execution workspace attached", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("No execution workspace accept");
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Mark this issue done?",
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId: null, projectId: null },
|
||||
created.id,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: created.id,
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -2283,6 +2284,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
|||
await db.delete(issueInboxArchives);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
|
|
@ -2452,6 +2454,179 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
|||
]);
|
||||
});
|
||||
|
||||
it("gates dependents on the workspace-finalize barrier when a done blocker's execution workspace has not synced back", async () => {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "QA",
|
||||
role: "qa",
|
||||
status: "active",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Shared workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Shared workspace",
|
||||
sourceType: "local_path",
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Shared exec workspace",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
});
|
||||
|
||||
const blockerId = randomUUID();
|
||||
const dependentId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: blockerId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Predecessor",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
},
|
||||
{
|
||||
id: dependentId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Dependent",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
},
|
||||
]);
|
||||
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
||||
|
||||
// A run touched the workspace (prepare phase) but has not yet recorded
|
||||
// workspace_finalize — the dependent must NOT wake.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
|
||||
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
||||
isDependencyReady: false,
|
||||
pendingFinalizeBlockerIssueIds: [blockerId],
|
||||
unresolvedBlockerIssueIds: [blockerId],
|
||||
});
|
||||
|
||||
// A failed finalize must keep the gate closed.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
|
||||
|
||||
// Once a workspace_finalize succeeded row lands AFTER the failed one,
|
||||
// the gate opens and the dependent is wakeable.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: dependentId,
|
||||
assigneeAgentId,
|
||||
blockerIssueIds: [blockerId],
|
||||
}),
|
||||
]);
|
||||
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
||||
isDependencyReady: true,
|
||||
pendingFinalizeBlockerIssueIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats blockers with no executionWorkspaceId as not subject to the workspace-finalize barrier", async () => {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "QA",
|
||||
role: "qa",
|
||||
status: "active",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const blockerId = randomUUID();
|
||||
const dependentId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
// Done blocker with no execution workspace ever attached (e.g. closed manually).
|
||||
{ id: blockerId, companyId, title: "Manual done blocker", status: "done", priority: "medium" },
|
||||
{
|
||||
id: dependentId,
|
||||
companyId,
|
||||
title: "Dependent",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
},
|
||||
]);
|
||||
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
||||
|
||||
// No executionWorkspaceId → no barrier → dependent should be wakeable.
|
||||
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: dependentId,
|
||||
assigneeAgentId,
|
||||
blockerIssueIds: [blockerId],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports dependency readiness for blocked issue chains", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
|
|
|
|||
|
|
@ -119,26 +119,126 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti
|
|||
};
|
||||
}
|
||||
|
||||
export type ExecutionWorkspaceEnvironmentSource =
|
||||
| "workspace"
|
||||
| "issue"
|
||||
| "project"
|
||||
| "agent"
|
||||
| "default";
|
||||
|
||||
export type ExecutionWorkspaceEnvironmentConflict = {
|
||||
reason: "reused_workspace_environment_mismatch";
|
||||
workspaceEnvironmentId: string;
|
||||
assigneeIntendedEnvironmentId: string;
|
||||
assigneeIntendedSource: Exclude<ExecutionWorkspaceEnvironmentSource, "workspace">;
|
||||
};
|
||||
|
||||
export type ExecutionWorkspaceEnvironmentResolution = {
|
||||
environmentId: string;
|
||||
source: ExecutionWorkspaceEnvironmentSource;
|
||||
conflict: ExecutionWorkspaceEnvironmentConflict | null;
|
||||
};
|
||||
|
||||
function resolveAssigneeIntendedExecutionWorkspaceEnvironment(input: {
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
agentDefaultEnvironmentId: string | null;
|
||||
defaultEnvironmentId: string;
|
||||
}): {
|
||||
environmentId: string;
|
||||
source: Exclude<ExecutionWorkspaceEnvironmentSource, "workspace">;
|
||||
} {
|
||||
// Explicit issue-level env override always wins, even for null-default
|
||||
// (local-only) agents. An operator who deliberately set
|
||||
// `executionWorkspaceSettings.environmentId` on this specific issue (see the
|
||||
// issues-service contract preserved in issues.ts:4243) chose that env for
|
||||
// this assignment and should not be silently downgraded to the local default
|
||||
// (PAPA-430 review fix). Inherited issue envs from
|
||||
// `inheritExecutionWorkspaceFromIssueId` are stripped before this point in
|
||||
// `resolveExecutionWorkspaceEnvironmentId`.
|
||||
if (input.issueSettings?.environmentId !== undefined) {
|
||||
return {
|
||||
environmentId: input.issueSettings.environmentId ?? input.defaultEnvironmentId,
|
||||
source: "issue",
|
||||
};
|
||||
}
|
||||
// A null defaultEnvironmentId on the agent means it is deliberately scoped to
|
||||
// the local default (e.g. Manual QA today). Project policy must not promote
|
||||
// such an agent off of local — only an explicit issue-level override above
|
||||
// can move the assignee away from the local default.
|
||||
if (input.agentDefaultEnvironmentId === null) {
|
||||
return { environmentId: input.defaultEnvironmentId, source: "default" };
|
||||
}
|
||||
if (input.projectPolicy?.environmentId !== undefined) {
|
||||
return {
|
||||
environmentId: input.projectPolicy.environmentId ?? input.defaultEnvironmentId,
|
||||
source: "project",
|
||||
};
|
||||
}
|
||||
return { environmentId: input.agentDefaultEnvironmentId, source: "agent" };
|
||||
}
|
||||
|
||||
export function resolveExecutionWorkspaceEnvironmentId(input: {
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
workspaceConfig: { environmentId?: string | null } | null;
|
||||
agentDefaultEnvironmentId: string | null;
|
||||
defaultEnvironmentId: string;
|
||||
}) {
|
||||
}): ExecutionWorkspaceEnvironmentResolution {
|
||||
// PAPA-431 companion: when the assignee has no explicit defaultEnvironmentId
|
||||
// (deliberately local-only, e.g. Manual QA) AND the issue settings env exactly
|
||||
// matches the reused workspace env, treat the issue env as a promoted artifact
|
||||
// from `inheritExecutionWorkspaceFromIssueId` rather than a deliberate
|
||||
// operator choice. Strip it so the resolver falls back to the local default
|
||||
// and the workspace-vs-intended conflict check forces a fresh realization.
|
||||
// A genuine operator override (via PATCH on the issue) reaches this code path
|
||||
// either with no reused workspace (workspaceConfig === null) or against a
|
||||
// workspace whose persisted env does not match the new override; both keep
|
||||
// the issue setting in place.
|
||||
const inheritedIssueEnvOnNullDefaultAssignee =
|
||||
input.agentDefaultEnvironmentId === null &&
|
||||
input.workspaceConfig?.environmentId !== undefined &&
|
||||
input.workspaceConfig?.environmentId !== null &&
|
||||
input.issueSettings?.environmentId !== undefined &&
|
||||
input.issueSettings.environmentId === input.workspaceConfig.environmentId;
|
||||
let issueSettingsForResolution = input.issueSettings;
|
||||
if (inheritedIssueEnvOnNullDefaultAssignee && input.issueSettings) {
|
||||
const { environmentId: _droppedInheritedEnv, ...rest } = input.issueSettings;
|
||||
void _droppedInheritedEnv;
|
||||
issueSettingsForResolution = rest as IssueExecutionWorkspaceSettings;
|
||||
}
|
||||
|
||||
const assigneeIntended = resolveAssigneeIntendedExecutionWorkspaceEnvironment({
|
||||
projectPolicy: input.projectPolicy,
|
||||
issueSettings: issueSettingsForResolution,
|
||||
agentDefaultEnvironmentId: input.agentDefaultEnvironmentId,
|
||||
defaultEnvironmentId: input.defaultEnvironmentId,
|
||||
});
|
||||
|
||||
if (input.workspaceConfig?.environmentId !== undefined) {
|
||||
return input.workspaceConfig.environmentId ?? input.defaultEnvironmentId;
|
||||
const workspaceEnvironmentId =
|
||||
input.workspaceConfig.environmentId ?? input.defaultEnvironmentId;
|
||||
// PAPA-380 / PAPA-431: a reused workspace's persisted environmentId must
|
||||
// never silently shadow the current assignee's environment identity.
|
||||
// When they disagree, refuse the silent reuse: return the assignee's
|
||||
// intended env and surface a conflict signal so the caller forces a fresh
|
||||
// workspace realization (or otherwise alerts the operator) instead of
|
||||
// running the agent on someone else's environment.
|
||||
if (workspaceEnvironmentId !== assigneeIntended.environmentId) {
|
||||
return {
|
||||
environmentId: assigneeIntended.environmentId,
|
||||
source: assigneeIntended.source,
|
||||
conflict: {
|
||||
reason: "reused_workspace_environment_mismatch",
|
||||
workspaceEnvironmentId,
|
||||
assigneeIntendedEnvironmentId: assigneeIntended.environmentId,
|
||||
assigneeIntendedSource: assigneeIntended.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { environmentId: workspaceEnvironmentId, source: "workspace", conflict: null };
|
||||
}
|
||||
if (input.issueSettings?.environmentId !== undefined) {
|
||||
return input.issueSettings.environmentId ?? input.defaultEnvironmentId;
|
||||
}
|
||||
if (input.projectPolicy?.environmentId !== undefined) {
|
||||
return input.projectPolicy.environmentId ?? input.defaultEnvironmentId;
|
||||
}
|
||||
if (input.agentDefaultEnvironmentId !== null) {
|
||||
return input.agentDefaultEnvironmentId;
|
||||
}
|
||||
return input.defaultEnvironmentId;
|
||||
return { environmentId: assigneeIntended.environmentId, source: assigneeIntended.source, conflict: null };
|
||||
}
|
||||
|
||||
export function defaultIssueExecutionWorkspaceSettingsForProject(
|
||||
|
|
|
|||
|
|
@ -7276,13 +7276,47 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
}
|
||||
const existingExecutionWorkspace =
|
||||
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
||||
const shouldReuseExisting =
|
||||
const requestedShouldReuseExisting =
|
||||
issueRef?.executionWorkspacePreference === "reuse_existing" &&
|
||||
existingExecutionWorkspace !== null &&
|
||||
existingExecutionWorkspace.status !== "archived";
|
||||
const reusableExecutionWorkspaceConfig = shouldReuseExisting
|
||||
const requestedReusableExecutionWorkspaceConfig = requestedShouldReuseExisting
|
||||
? existingExecutionWorkspace?.config ?? null
|
||||
: null;
|
||||
const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
|
||||
const environmentResolution = resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
workspaceConfig: requestedReusableExecutionWorkspaceConfig,
|
||||
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
|
||||
defaultEnvironmentId: defaultEnvironment.id,
|
||||
});
|
||||
// PAPA-380 / PAPA-431: when the resolver refuses silent reuse of the
|
||||
// persisted workspace environment, also force a fresh workspace
|
||||
// realization on the assignee's intended env. Reusing the on-disk
|
||||
// workspace while swapping the env underneath it would mismatch the cwd's
|
||||
// runtime expectations (e.g. an SSH-targeted worktree running on the
|
||||
// local default driver).
|
||||
if (environmentResolution.conflict) {
|
||||
logger.warn(
|
||||
{
|
||||
runId: run.id,
|
||||
issueId,
|
||||
agentId: agent.id,
|
||||
adapterType: agent.adapterType,
|
||||
existingExecutionWorkspaceId: existingExecutionWorkspace?.id ?? null,
|
||||
workspaceEnvironmentId: environmentResolution.conflict.workspaceEnvironmentId,
|
||||
assigneeIntendedEnvironmentId:
|
||||
environmentResolution.conflict.assigneeIntendedEnvironmentId,
|
||||
assigneeIntendedSource: environmentResolution.conflict.assigneeIntendedSource,
|
||||
},
|
||||
"Refusing silent reuse of execution workspace whose environment does not match the assignee's intended environment; forcing fresh realization",
|
||||
);
|
||||
}
|
||||
const shouldReuseExisting = requestedShouldReuseExisting && !environmentResolution.conflict;
|
||||
const reusableExecutionWorkspaceConfig = shouldReuseExisting
|
||||
? requestedReusableExecutionWorkspaceConfig
|
||||
: null;
|
||||
const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace
|
||||
? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode)
|
||||
: null;
|
||||
|
|
@ -7292,14 +7326,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
persistedExecutionWorkspaceMode === "agent_default"
|
||||
? persistedExecutionWorkspaceMode
|
||||
: requestedExecutionWorkspaceMode;
|
||||
const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
|
||||
const selectedEnvironmentId = resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
workspaceConfig: reusableExecutionWorkspaceConfig,
|
||||
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
|
||||
defaultEnvironmentId: defaultEnvironment.id,
|
||||
});
|
||||
const selectedEnvironmentId = environmentResolution.environmentId;
|
||||
const workspaceManagedConfig = shouldReuseExisting
|
||||
? { ...config }
|
||||
: buildExecutionWorkspaceAdapterConfig({
|
||||
|
|
@ -7980,31 +8007,80 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
"local agent jwt secret missing or invalid; running without injected PAPERCLIP_API_KEY",
|
||||
);
|
||||
}
|
||||
const adapterResult = await adapter.execute({
|
||||
runId: run.id,
|
||||
agent,
|
||||
runtime: runtimeForAdapter,
|
||||
config: runtimeConfig,
|
||||
context,
|
||||
runtimeCommandSpec: adapter.getRuntimeCommandSpec?.(runtimeConfig) ?? null,
|
||||
executionTarget,
|
||||
executionTransport: remoteExecution
|
||||
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
|
||||
: undefined,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onSpawn: async (meta) => {
|
||||
await persistRunProcessMetadata(run.id, {
|
||||
pid: meta.pid,
|
||||
processGroupId:
|
||||
"processGroupId" in meta && typeof meta.processGroupId === "number"
|
||||
? meta.processGroupId
|
||||
: null,
|
||||
startedAt: meta.startedAt,
|
||||
let adapterFinalizeOutcome: "succeeded" | "failed" | null = null;
|
||||
const recordWorkspaceFinalize = async (
|
||||
status: "succeeded" | "failed",
|
||||
metadata?: Record<string, unknown>,
|
||||
) => {
|
||||
if (adapterFinalizeOutcome) return;
|
||||
await workspaceOperationRecorder.recordOperation({
|
||||
phase: "workspace_finalize",
|
||||
cwd: executionWorkspace.cwd,
|
||||
metadata: {
|
||||
adapterType: agent.adapterType,
|
||||
executionTargetKind: executionTarget?.kind ?? "local",
|
||||
...metadata,
|
||||
},
|
||||
run: async () => ({ status }),
|
||||
});
|
||||
// Only mark the outcome after the row landed, so a transient write
|
||||
// failure on the succeeded path can still be recovered by recording
|
||||
// finalize=failed from the catch path below.
|
||||
adapterFinalizeOutcome = status;
|
||||
};
|
||||
|
||||
let adapterResult: Awaited<ReturnType<typeof adapter.execute>>;
|
||||
try {
|
||||
adapterResult = await adapter.execute({
|
||||
runId: run.id,
|
||||
agent,
|
||||
runtime: runtimeForAdapter,
|
||||
config: runtimeConfig,
|
||||
context,
|
||||
runtimeCommandSpec: adapter.getRuntimeCommandSpec?.(runtimeConfig) ?? null,
|
||||
executionTarget,
|
||||
executionTransport: remoteExecution
|
||||
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
|
||||
: undefined,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onSpawn: async (meta) => {
|
||||
await persistRunProcessMetadata(run.id, {
|
||||
pid: meta.pid,
|
||||
processGroupId:
|
||||
"processGroupId" in meta && typeof meta.processGroupId === "number"
|
||||
? meta.processGroupId
|
||||
: null,
|
||||
startedAt: meta.startedAt,
|
||||
});
|
||||
},
|
||||
authToken: authToken ?? undefined,
|
||||
});
|
||||
// Adapter returned cleanly, which means its workspace-restore finally
|
||||
// block also ran without throwing. Record the workspace_finalize
|
||||
// barrier so dependents that share this executionWorkspace can wake.
|
||||
// If recording the barrier itself fails, propagate as a run failure
|
||||
// rather than silently leaving dependents stranded behind a missing
|
||||
// finalize row.
|
||||
await recordWorkspaceFinalize("succeeded");
|
||||
} catch (adapterErr) {
|
||||
// Adapter (or its restore finally) threw — or the finalize record
|
||||
// write itself threw. Either way the workspace may be in a partial
|
||||
// state. Best-effort record finalize=failed so the dependent readiness
|
||||
// check keeps the gate closed instead of waking on stale local state,
|
||||
// and surface the original error to the caller.
|
||||
try {
|
||||
await recordWorkspaceFinalize("failed", {
|
||||
errorMessage: adapterErr instanceof Error ? adapterErr.message : String(adapterErr),
|
||||
});
|
||||
},
|
||||
authToken: authToken ?? undefined,
|
||||
});
|
||||
} catch (recordErr) {
|
||||
logger.warn(
|
||||
{ err: recordErr, runId: run.id, executionWorkspaceId: persistedExecutionWorkspace?.id ?? null },
|
||||
"failed to record workspace_finalize=failed operation; dependents may remain gated",
|
||||
);
|
||||
}
|
||||
throw adapterErr;
|
||||
}
|
||||
const adapterManagedRuntimeServices = adapterResult.runtimeServices
|
||||
? await persistAdapterManagedRuntimeServices({
|
||||
db,
|
||||
|
|
@ -8250,6 +8326,54 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
: livenessRun,
|
||||
agent,
|
||||
);
|
||||
|
||||
// Workspace-finalize wake re-fire: if this run's issue was marked done
|
||||
// mid-run (so the original `issue_blockers_resolved` wake was gated by
|
||||
// the readiness check waiting for workspace_finalize), the finalize
|
||||
// row we just recorded now lets dependents proceed. Fire wakes here.
|
||||
if (issueId && adapterFinalizeOutcome === "succeeded") {
|
||||
try {
|
||||
const blockerIssueStatus = await db
|
||||
.select({ status: issues.status })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0]?.status ?? null);
|
||||
if (blockerIssueStatus === "done") {
|
||||
const dependents = await issuesSvc.listWakeableBlockedDependents(issueId);
|
||||
for (const dependent of dependents) {
|
||||
await enqueueWakeup(dependent.assigneeAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_blockers_resolved",
|
||||
payload: {
|
||||
issueId: dependent.id,
|
||||
resolvedBlockerIssueId: issueId,
|
||||
blockerIssueIds: dependent.blockerIssueIds,
|
||||
deferredFor: "workspace_finalize",
|
||||
},
|
||||
contextSnapshot: {
|
||||
issueId: dependent.id,
|
||||
taskId: dependent.id,
|
||||
wakeReason: "issue_blockers_resolved",
|
||||
source: "workspace.finalize",
|
||||
resolvedBlockerIssueId: issueId,
|
||||
blockerIssueIds: dependent.blockerIssueIds,
|
||||
},
|
||||
}).catch((wakeErr) => {
|
||||
logger.warn(
|
||||
{ err: wakeErr, issueId, dependentIssueId: dependent.id, agentId: dependent.assigneeAgentId },
|
||||
"failed to fire deferred dependent wake after workspace_finalize",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (finalizeWakeErr) {
|
||||
logger.warn(
|
||||
{ err: finalizeWakeErr, runId: run.id, issueId },
|
||||
"failed to evaluate dependent wakes after workspace_finalize",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalizedRun) {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import {
|
|||
suggestTasksResultSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { issueService, listUnfinalizedExecutionWorkspaceIds } from "./issues.js";
|
||||
|
||||
type InteractionActor = {
|
||||
agentId?: string | null;
|
||||
|
|
@ -457,6 +457,32 @@ export function issueThreadInteractionService(db: Db) {
|
|||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function assertIssueWorkspaceFinalizedForAccept(args: {
|
||||
db: Pick<Db, "select">;
|
||||
issue: { id: string; companyId: string };
|
||||
}) {
|
||||
const executionWorkspaceId = await args.db
|
||||
.select({ executionWorkspaceId: issues.executionWorkspaceId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, args.issue.id))
|
||||
.then((rows: Array<{ executionWorkspaceId: string | null }>) => rows[0]?.executionWorkspaceId ?? null);
|
||||
|
||||
if (!executionWorkspaceId) return;
|
||||
|
||||
const unfinalized = await listUnfinalizedExecutionWorkspaceIds(
|
||||
args.db,
|
||||
args.issue.companyId,
|
||||
[executionWorkspaceId],
|
||||
);
|
||||
if (!unfinalized.has(executionWorkspaceId)) return;
|
||||
|
||||
throw conflict(
|
||||
"Cannot accept interaction: the issue's most recent run has not completed workspace_finalize. "
|
||||
+ "Retry once the local worktree has finished syncing.",
|
||||
{ executionWorkspaceId },
|
||||
);
|
||||
}
|
||||
|
||||
async function getPendingInteractionForResolution(args: {
|
||||
issue: { id: string; companyId: string };
|
||||
interactionId: string;
|
||||
|
|
@ -747,8 +773,12 @@ export function issueThreadInteractionService(db: Db) {
|
|||
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
||||
switch (current.kind) {
|
||||
case "suggest_tasks":
|
||||
// Accepting suggest_tasks only creates follow-up issues; it does not
|
||||
// approve code state or move the source workspace forward, so the
|
||||
// workspace_finalize gate (PAPA-440) does not apply here.
|
||||
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor);
|
||||
case "request_confirmation": {
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
|
||||
const accepted = await acceptRequestConfirmation({
|
||||
issue,
|
||||
current,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
labels,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
AcceptedPlanDecomposition,
|
||||
|
|
@ -351,6 +352,8 @@ export type IssueDependencyReadiness = {
|
|||
blockerIssueIds: string[];
|
||||
unresolvedBlockerIssueIds: string[];
|
||||
unresolvedBlockerCount: number;
|
||||
/** Blockers whose status is `done` but whose execution workspace has not yet finalized. */
|
||||
pendingFinalizeBlockerIssueIds: string[];
|
||||
allBlockersDone: boolean;
|
||||
isDependencyReady: boolean;
|
||||
};
|
||||
|
|
@ -582,11 +585,70 @@ function createIssueDependencyReadiness(issueId: string): IssueDependencyReadine
|
|||
blockerIssueIds: [],
|
||||
unresolvedBlockerIssueIds: [],
|
||||
unresolvedBlockerCount: 0,
|
||||
pendingFinalizeBlockerIssueIds: [],
|
||||
allBlockersDone: true,
|
||||
isDependencyReady: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of execution-workspace ids whose most recent workspace operation
|
||||
* is NOT a successful `workspace_finalize`. These workspaces have either an in-flight
|
||||
* run, a failed finalize, or never reached the finalize barrier — dependents that
|
||||
* read this workspace must wait until finalize succeeds.
|
||||
*
|
||||
* Workspaces with no recorded operations are considered finalized (nothing has
|
||||
* touched them since they were realized).
|
||||
*/
|
||||
export async function listUnfinalizedExecutionWorkspaceIds(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
executionWorkspaceIds: string[],
|
||||
): Promise<Set<string>> {
|
||||
const unfinalized = new Set<string>();
|
||||
if (executionWorkspaceIds.length === 0) return unfinalized;
|
||||
|
||||
// Pull every workspace op for the candidate workspaces and pick the latest per
|
||||
// workspace in memory. Per-workspace LATERAL queries would be tighter, but the
|
||||
// candidate set is tiny in practice (one workspace per blocker per readiness call).
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
|
||||
phase: workspaceOperations.phase,
|
||||
status: workspaceOperations.status,
|
||||
startedAt: workspaceOperations.startedAt,
|
||||
})
|
||||
.from(workspaceOperations)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceOperations.companyId, companyId),
|
||||
inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds),
|
||||
),
|
||||
);
|
||||
|
||||
const latestByWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
|
||||
for (const row of rows) {
|
||||
if (!row.executionWorkspaceId) continue;
|
||||
const current = latestByWorkspace.get(row.executionWorkspaceId);
|
||||
if (!current || row.startedAt > current.startedAt) {
|
||||
latestByWorkspace.set(row.executionWorkspaceId, {
|
||||
phase: row.phase,
|
||||
status: row.status,
|
||||
startedAt: row.startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const workspaceId of executionWorkspaceIds) {
|
||||
const latest = latestByWorkspace.get(workspaceId);
|
||||
if (!latest) continue; // no ops recorded → treat as finalized
|
||||
if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue;
|
||||
unfinalized.add(workspaceId);
|
||||
}
|
||||
|
||||
return unfinalized;
|
||||
}
|
||||
|
||||
async function listIssueDependencyReadinessMap(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
|
|
@ -604,6 +666,7 @@ async function listIssueDependencyReadinessMap(
|
|||
issueId: issueRelations.relatedIssueId,
|
||||
blockerIssueId: issueRelations.issueId,
|
||||
blockerStatus: issues.status,
|
||||
blockerExecutionWorkspaceId: issues.executionWorkspaceId,
|
||||
})
|
||||
.from(issueRelations)
|
||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||
|
|
@ -615,6 +678,21 @@ async function listIssueDependencyReadinessMap(
|
|||
),
|
||||
);
|
||||
|
||||
// Collect executionWorkspaceIds of "done" blockers — these are the only ones
|
||||
// subject to the workspace-finalize barrier. Blockers that aren't done already
|
||||
// mark the dependent as not-ready and don't need a finalize check.
|
||||
const doneBlockerWorkspaceIds = new Set<string>();
|
||||
for (const row of blockerRows) {
|
||||
if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) {
|
||||
doneBlockerWorkspaceIds.add(row.blockerExecutionWorkspaceId);
|
||||
}
|
||||
}
|
||||
const unfinalizedWorkspaceIds = await listUnfinalizedExecutionWorkspaceIds(
|
||||
dbOrTx,
|
||||
companyId,
|
||||
[...doneBlockerWorkspaceIds],
|
||||
);
|
||||
|
||||
for (const row of blockerRows) {
|
||||
const current = readinessMap.get(row.issueId) ?? createIssueDependencyReadiness(row.issueId);
|
||||
current.blockerIssueIds.push(row.blockerIssueId);
|
||||
|
|
@ -625,6 +703,21 @@ async function listIssueDependencyReadinessMap(
|
|||
current.unresolvedBlockerCount += 1;
|
||||
current.allBlockersDone = false;
|
||||
current.isDependencyReady = false;
|
||||
} else if (
|
||||
row.blockerExecutionWorkspaceId &&
|
||||
unfinalizedWorkspaceIds.has(row.blockerExecutionWorkspaceId)
|
||||
) {
|
||||
// Workspace-finalize barrier: the blocker's most recent run on its
|
||||
// execution workspace hasn't recorded a successful workspace_finalize.
|
||||
// Treat the dependent as not-ready until sync-back lands (or the run
|
||||
// finalizes); a subsequent finalize wake will re-evaluate readiness.
|
||||
// `allBlockersDone` is cleared too so that callers using it as a
|
||||
// proxy for "this dependent can proceed" still see the gate.
|
||||
current.unresolvedBlockerIssueIds.push(row.blockerIssueId);
|
||||
current.unresolvedBlockerCount += 1;
|
||||
current.pendingFinalizeBlockerIssueIds.push(row.blockerIssueId);
|
||||
current.allBlockersDone = false;
|
||||
current.isDependencyReady = false;
|
||||
}
|
||||
readinessMap.set(row.issueId, current);
|
||||
}
|
||||
|
|
@ -4091,45 +4184,33 @@ export function issueService(db: Db) {
|
|||
);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const candidateIds = candidates.map((candidate) => candidate.id);
|
||||
const blockerRows = await db
|
||||
.select({
|
||||
issueId: issueRelations.relatedIssueId,
|
||||
blockerIssueId: issueRelations.issueId,
|
||||
blockerStatus: issues.status,
|
||||
})
|
||||
.from(issueRelations)
|
||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueRelations.companyId, blockerIssue.companyId),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
inArray(issueRelations.relatedIssueId, candidateIds),
|
||||
),
|
||||
);
|
||||
const wakeableCandidates = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.assigneeAgentId && !["backlog", "done", "cancelled"].includes(candidate.status),
|
||||
);
|
||||
if (wakeableCandidates.length === 0) return [];
|
||||
|
||||
const blockersByIssueId = new Map<string, Array<{ blockerIssueId: string; blockerStatus: string }>>();
|
||||
for (const row of blockerRows) {
|
||||
const list = blockersByIssueId.get(row.issueId) ?? [];
|
||||
list.push({ blockerIssueId: row.blockerIssueId, blockerStatus: row.blockerStatus });
|
||||
blockersByIssueId.set(row.issueId, list);
|
||||
}
|
||||
// Defer to the unified readiness check so that a dependent only fires when
|
||||
// (a) every blocker is done AND (b) every done blocker's workspace has
|
||||
// recorded a successful workspace_finalize. The finalize hook also calls
|
||||
// this function on completion, so a wake initially gated by an in-flight
|
||||
// sync-back will re-fire once the restore lands locally.
|
||||
const readinessMap = await listIssueDependencyReadinessMap(
|
||||
db,
|
||||
blockerIssue.companyId,
|
||||
wakeableCandidates.map((candidate) => candidate.id),
|
||||
);
|
||||
|
||||
return candidates
|
||||
.filter((candidate) => candidate.assigneeAgentId && !["backlog", "done", "cancelled"].includes(candidate.status))
|
||||
return wakeableCandidates
|
||||
.map((candidate) => {
|
||||
const blockers = blockersByIssueId.get(candidate.id) ?? [];
|
||||
return {
|
||||
...candidate,
|
||||
blockerIssueIds: blockers.map((blocker) => blocker.blockerIssueId),
|
||||
allBlockersDone: blockers.length > 0 && blockers.every((blocker) => blocker.blockerStatus === "done"),
|
||||
};
|
||||
const readiness = readinessMap.get(candidate.id) ?? createIssueDependencyReadiness(candidate.id);
|
||||
return { candidate, readiness };
|
||||
})
|
||||
.filter((candidate) => candidate.allBlockersDone)
|
||||
.map((candidate) => ({
|
||||
.filter(({ readiness }) => readiness.isDependencyReady && readiness.blockerIssueIds.length > 0)
|
||||
.map(({ candidate, readiness }) => ({
|
||||
id: candidate.id,
|
||||
assigneeAgentId: candidate.assigneeAgentId!,
|
||||
blockerIssueIds: candidate.blockerIssueIds,
|
||||
blockerIssueIds: readiness.blockerIssueIds,
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue