fix: propagate projectId into wakeup context and support identifier lookup (#6026)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The server's heartbeat/wakeup pipeline resolves which project
workspace an agent run should bind to
> - `enqueueWakeup` resolves an issue (and therefore a project) before
scheduling a run, but the resolved `projectId` was never written back
into `enrichedContextSnapshot.projectId`, so `resolveWorkspaceForRun`
always saw `contextProjectId === null`
> - When the `issueProjectRef` DB lookup also returned null (e.g.
identifier-style id like `ENV-13`, not a UUID), workspace resolution
fell through to the `agent_home` fallback instead of the correct project
workspace
> - Surfaced while running the QA matrix on sandbox/SSH — runs were
ending up in the wrong workspace
> - This pull request stores the resolved `projectId` back into context
and replaces the raw UUID-only DB query with `issuesSvc.getById`, which
accepts both UUIDs and identifiers and canonicalizes `context.issueId` /
`context.taskId` to the UUID on identifier hits
> - The benefit is that wakeups triggered with identifier-style ids
correctly bind to their project workspace instead of silently degrading
to `agent_home`

## What Changed

- In `enqueueWakeup`, after the issue resolves, write `projectId` back
into `enrichedContextSnapshot.projectId` so downstream workspace
resolution can use it.
- Replace the raw UUID-only DB query for the issue with
`issuesSvc.getById`, which handles both UUIDs and identifiers (e.g.
`ENV-13`).
- On an identifier hit, canonicalize `context.issueId` and
`context.taskId` to the resolved UUID.

## Verification

- Trigger a wakeup with an identifier-style id (`ENV-13`) on the dev
instance and confirm the run binds to the correct project workspace
instead of `agent_home`.
- Confirm UUID-style wakeups still resolve to the same project workspace
as before.

## Risks

- Low risk. Scope is a single function in
`server/src/services/heartbeat.ts` (+20/-7). Failure mode if regressed
is the prior behavior (fallback to `agent_home`).

## Model Used

- Claude (Anthropic), `claude-opus-4-7`, via Claude Code / Paperclip
`claude_local` 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
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [ ] 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley
2026-05-14 22:09:16 -07:00
committed by GitHub
parent 333a16b035
commit 901c088e14
+33 -7
View File
@@ -159,7 +159,7 @@ import {
readPaperclipSkillSyncPreference,
writePaperclipSkillSyncPreference,
} from "@paperclipai/adapter-utils/server-utils";
import { extractSkillMentionIds } from "@paperclipai/shared";
import { extractSkillMentionIds, isUuidLike } from "@paperclipai/shared";
import { environmentService } from "./environments.js";
import { environmentRuntimeService } from "./environment-runtime.js";
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
@@ -1777,7 +1777,7 @@ function enrichWakeContextSnapshot(input: {
payload: Record<string, unknown> | null;
}) {
const { contextSnapshot, reason, source, triggerDetail, payload } = input;
const issueIdFromPayload = readNonEmptyString(payload?.["issueId"]);
const issueIdFromPayload = readNonEmptyString(payload?.["issueId"]) ?? readNonEmptyString(payload?.["taskId"]);
const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]);
const taskKey = deriveTaskKey(contextSnapshot, payload);
const wakeCommentId = deriveCommentId(contextSnapshot, payload);
@@ -3419,7 +3419,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
previousSessionParams: Record<string, unknown> | null,
opts?: { useProjectWorkspace?: boolean | null },
): Promise<ResolvedWorkspaceForRun> {
const issueId = readNonEmptyString(context.issueId);
const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
const contextProjectId = readNonEmptyString(context.projectId);
const contextProjectWorkspaceId = readNonEmptyString(context.projectWorkspaceId);
const issueProjectRef = issueId
@@ -8606,11 +8606,37 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
let projectId = readNonEmptyString(enrichedContextSnapshot.projectId);
if (!projectId && issueId) {
projectId = await db
.select({ projectId: issues.projectId })
// Look up by either UUID or identifier (e.g. "ENV-13"), but always scope
// by companyId so a row from another tenant can never be returned even
// when identifiers collide across companies. Guard the UUID arm because
// issues.id is a Postgres uuid column — passing "ENV-13" into eq(issues.id, …)
// would fail with an invalid-input-syntax cast error before the OR is
// evaluated.
const lookupIsUuid = isUuidLike(issueId);
const idMatch = lookupIsUuid
? or(eq(issues.id, issueId), eq(issues.identifier, issueId.toUpperCase()))
: eq(issues.identifier, issueId.toUpperCase());
const resolvedIssue = await db
.select({ id: issues.id, projectId: issues.projectId })
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
.then((rows) => rows[0]?.projectId ?? null);
.where(and(eq(issues.companyId, agent.companyId), idMatch))
.then((rows) => rows[0] ?? null);
if (resolvedIssue) {
projectId = resolvedIssue.projectId ?? null;
// Canonicalize context to the UUID so downstream lookups always use UUID
if (resolvedIssue.id !== issueId) {
issueId = resolvedIssue.id;
enrichedContextSnapshot.issueId = issueId;
if (readNonEmptyString(enrichedContextSnapshot.taskId)) {
enrichedContextSnapshot.taskId = issueId;
}
}
}
}
// Propagate projectId into context so resolveWorkspaceForRun can bind the
// project workspace even when context.projectId wasn't set by the caller.
if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) {
enrichedContextSnapshot.projectId = projectId;
}
const budgetBlock = await budgets.getInvocationBlock(agent.companyId, agentId, {