Keep manual routine runs visible in the runner inbox (#4615)

## Thinking Path

> - Paperclip coordinates recurring agent work through scheduled and
manual routines.
> - Manual routine runs are board-initiated work and should stay visible
to the human who kicked them off.
> - Routine execution issues are agent-assigned, so they can be filtered
away from a board user's inbox unless the user is recorded as touching
the work.
> - Coalesced or skipped active routine runs have the same visibility
problem because they reuse an existing live issue.
> - This pull request carries the manual runner actor into routine
dispatch and touches the linked issue for that user's inbox.
> - The benefit is that manually triggered routine work stays
discoverable by the operator who started it.

## What Changed

- Passed the board or agent actor from the routine run route into the
routine service.
- Recorded manual board runners as `createdByUserId` on fresh routine
execution issues.
- Touched coalesced or skipped active routine issues for the manual
runner by updating read state and clearing that user's inbox archive.
- Added route and service regressions for manual routine run actor
propagation and inbox visibility.

## Verification

- `pnpm exec vitest run server/src/__tests__/routines-routes.test.ts
server/src/__tests__/routines-service.test.ts`

## Risks

- Low risk: the change is scoped to manual routine runs and only updates
issue attribution/read-state metadata for the initiating actor.
- No migrations.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent based on GPT-5, tool-enabled local
repository and shell access, Paperclip heartbeat context.

## 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
- [x] If this change affects the UI, I have included before/after
screenshots
- [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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-27 20:03:24 -05:00 committed by GitHub
parent 68c37660f0
commit f88f538e6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 282 additions and 2 deletions

View file

@ -7,6 +7,8 @@ import {
executionWorkspaces,
goals,
heartbeatRuns,
issueInboxArchives,
issueReadStates,
issues,
projects,
routineRuns,
@ -758,6 +760,43 @@ export function routineService(
return value;
}
async function touchIssueForUserInbox(
executor: Db,
input: {
companyId: string;
issueId: string;
userId: string;
touchedAt: Date;
},
) {
await executor
.insert(issueReadStates)
.values({
companyId: input.companyId,
issueId: input.issueId,
userId: input.userId,
lastReadAt: input.touchedAt,
updatedAt: input.touchedAt,
})
.onConflictDoUpdate({
target: [issueReadStates.companyId, issueReadStates.issueId, issueReadStates.userId],
set: {
lastReadAt: input.touchedAt,
updatedAt: input.touchedAt,
},
});
await executor
.delete(issueInboxArchives)
.where(
and(
eq(issueInboxArchives.companyId, input.companyId),
eq(issueInboxArchives.issueId, input.issueId),
eq(issueInboxArchives.userId, input.userId),
),
);
}
async function dispatchRoutineRun(input: {
routine: typeof routines.$inferSelect;
trigger: typeof routineTriggers.$inferSelect | null;
@ -770,6 +809,7 @@ export function routineService(
executionWorkspaceId?: string | null;
executionWorkspacePreference?: string | null;
executionWorkspaceSettings?: Record<string, unknown> | null;
actor?: Actor;
}) {
const projectId = input.projectId ?? input.routine.projectId ?? null;
const assigneeAgentId = input.assigneeAgentId ?? input.routine.assigneeAgentId ?? null;
@ -840,6 +880,7 @@ export function routineService(
}
const triggeredAt = new Date();
const manualRunnerUserId = input.source === "manual" ? input.actor?.userId ?? null : null;
const [createdRun] = await txDb
.insert(routineRuns)
.values({
@ -864,6 +905,14 @@ export function routineService(
const activeIssue = await findLiveExecutionIssue(input.routine, txDb, dispatchFingerprint);
if (activeIssue && input.routine.concurrencyPolicy !== "always_enqueue") {
const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced";
if (manualRunnerUserId) {
await touchIssueForUserInbox(txDb, {
companyId: input.routine.companyId,
issueId: activeIssue.id,
userId: manualRunnerUserId,
touchedAt: triggeredAt,
});
}
const updated = await finalizeRun(createdRun.id, {
status,
linkedIssueId: activeIssue.id,
@ -891,6 +940,8 @@ export function routineService(
status: "todo",
priority: input.routine.priority,
assigneeAgentId,
createdByAgentId: input.source === "manual" ? input.actor?.agentId ?? null : null,
createdByUserId: manualRunnerUserId,
originKind: "routine_execution",
originId: input.routine.id,
originRunId: createdRun.id,
@ -914,6 +965,14 @@ export function routineService(
const existingIssue = await findLiveExecutionIssue(input.routine, txDb, dispatchFingerprint);
if (!existingIssue) throw error;
const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced";
if (manualRunnerUserId) {
await touchIssueForUserInbox(txDb, {
companyId: input.routine.companyId,
issueId: existingIssue.id,
userId: manualRunnerUserId,
touchedAt: triggeredAt,
});
}
const updated = await finalizeRun(createdRun.id, {
status,
linkedIssueId: existingIssue.id,
@ -1383,7 +1442,7 @@ export function routineService(
};
},
runRoutine: async (id: string, input: RunRoutine) => {
runRoutine: async (id: string, input: RunRoutine, actor?: Actor) => {
const routine = await getRoutineById(id);
if (!routine) throw notFound("Routine not found");
if (routine.status === "archived") throw conflict("Routine is archived");
@ -1405,6 +1464,7 @@ export function routineService(
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
executionWorkspaceSettings:
(input.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null,
actor,
});
},