mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-21 04:50:37 +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
|
|
@ -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