2026-03-21 12:20:48 -05:00
|
|
|
import { randomUUID } from "node:crypto";
|
2026-04-02 11:38:57 -05:00
|
|
|
import { eq } from "drizzle-orm";
|
2026-03-21 12:20:48 -05:00
|
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
2026-04-04 13:56:04 -05:00
|
|
|
import { sql } from "drizzle-orm";
|
2026-03-21 12:20:48 -05:00
|
|
|
import {
|
|
|
|
|
activityLog,
|
|
|
|
|
agents,
|
|
|
|
|
companies,
|
|
|
|
|
createDb,
|
Honor reuse-existing preference and assignee default environment in issue runs (#5139)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Agents run inside execution workspaces (a per-issue cwd + env), and
an issue
> can prefer to reuse an existing workspace or get a fresh one each time
> - The heartbeat service was reading the existing workspace's config to
derive
> environment selection regardless of whether the issue actually wanted
to reuse
> it. So fresh-run issues were inheriting stale config from a workspace
that was
> about to be discarded
> - Separately, when an issue is assigned to an agent, the issue's
execution
> workspace settings weren't picking up the agent's
`defaultEnvironmentId`,
> even though the agent's choice is the natural default for that issue
> - This PR makes both selection paths honor the obvious source of
truth:
> workspace config flows only when the issue actually wants
`reuse_existing`,
> and the assignee agent's default environment is applied at assignment
time if
> nothing else is set on the issue
> - The benefit is that re-running a flaky issue picks up the right
environment
> instead of inheriting the previous run's config, and assigning an
agent to an
> issue does the obvious thing without operator intervention
## What Changed
- `server/src/services/heartbeat.ts`: introduce
`reusableExecutionWorkspaceConfig`
that is non-null only when `shouldReuseExisting` is true. Both
`resolveExecutionWorkspaceEnvironmentId(...)` and
`applyPersistedExecutionWorkspaceConfig(...)` now read from it instead
of
unconditionally consulting `existingExecutionWorkspace?.config`.
Fresh-run
issues no longer inherit stale environment config from an in-flight
workspace
about to be discarded.
- `server/src/services/issues.ts`: when an issue update sets a new
`assigneeAgentId` and isolated workspaces are enabled, populate
`executionWorkspaceSettings.environmentId` from the assignee agent's
`defaultEnvironmentId` if the issue doesn't have an explicit
`environmentId` set yet.
- Tests added in `heartbeat-plugin-environment.test.ts` (~216 lines) and
`issues-service.test.ts` (~85 lines) covering both paths.
## Verification
- `pnpm --filter @paperclipai/server test --
heartbeat-plugin-environment issues-service`
- Manual QA: assign an issue to an agent that has a non-default
`defaultEnvironmentId`, confirm the issue's workspace settings now
include that
environment id without operator intervention. Trigger a rerun on an
issue
whose existing workspace points at a stale environment, confirm the
rerun uses
the freshly-resolved environment.
## Risks
- Behavioural shift on assignment: previously assigning an agent didn't
propagate the agent's default environment to the issue. Now it does.
Callers
that explicitly want the issue to keep its existing/null environment
must set
`executionWorkspaceSettings.environmentId` themselves; the new logic
only
fires when no explicit value is set.
- Behavioural shift on rerun: stale workspace config is no longer
applied to
fresh runs. Operators who relied on this implicit inheritance may see
different environment selection on the first rerun after deploy.
Mitigation:
the explicit isssue settings and project policy are still honored as
before.
## Model Used
- OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI
- Provider: OpenAI
- Used to author the code changes in this PR
## 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 (no UI changes)
- [ ] I have updated relevant documentation to reflect my changes — N/A
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-05-03 18:33:55 -07:00
|
|
|
environments,
|
2026-03-28 22:21:24 -05:00
|
|
|
executionWorkspaces,
|
[codex] Add run liveness continuations (#4083)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - Heartbeat runs are the control-plane record of each agent execution
window.
> - Long-running local agents can exhaust context or stop while still
holding useful next-step state.
> - Operators need that stop reason, next action, and continuation path
to be durable and visible.
> - This pull request adds run liveness metadata, continuation
summaries, and UI surfaces for issue run ledgers.
> - The benefit is that interrupted or long-running work can resume with
clearer context instead of losing the agent's last useful handoff.
## What Changed
- Added heartbeat-run liveness fields, continuation attempt tracking,
and an idempotent `0058` migration.
- Added server services and tests for run liveness, continuation
summaries, stop metadata, and activity backfill.
- Wired local and HTTP adapters to surface continuation/liveness context
through shared adapter utilities.
- Added shared constants, validators, and heartbeat types for liveness
continuation state.
- Added issue-detail UI surfaces for continuation handoffs and the run
ledger, with component tests.
- Updated agent runtime docs, heartbeat protocol docs, prompt guidance,
onboarding assets, and skills instructions to explain continuation
behavior.
- Addressed Greptile feedback by scoping document evidence by run,
excluding system continuation-summary documents from liveness evidence,
importing shared liveness types, surfacing hidden ledger run counts,
documenting bounded retry behavior, and moving run-ledger liveness
backfill off the request path.
## Verification
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/run-continuations.test.ts
server/src/__tests__/run-liveness.test.ts
server/src/__tests__/activity-service.test.ts
server/src/__tests__/documents-service.test.ts
server/src/__tests__/issue-continuation-summary.test.ts
server/src/services/heartbeat-stop-metadata.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/components/IssueContinuationHandoff.test.tsx
ui/src/components/IssueDocumentsSection.test.tsx`
- `pnpm --filter @paperclipai/db build`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/run-continuations.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "treats a
plan document update"`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts -t "activity
service|treats a plan document update"`
- Remote PR checks on head `e53b1a1d`: `verify`, `e2e`, `policy`, and
Snyk all passed.
- Confirmed `public-gh/master` is an ancestor of this branch after
fetching `public-gh master`.
- Confirmed `pnpm-lock.yaml` is not included in the branch diff.
- Confirmed migration `0058_wealthy_starbolt.sql` is ordered after
`0057` and uses `IF NOT EXISTS` guards for repeat application.
- Greptile inline review threads are resolved.
## Risks
- Medium risk: this touches heartbeat execution, liveness recovery,
activity rendering, issue routes, shared contracts, docs, and UI.
- Migration risk is mitigated by additive columns/indexes and idempotent
guards.
- Run-ledger liveness backfill is now asynchronous, so the first ledger
response can briefly show historical missing liveness until the
background backfill completes.
- UI screenshot coverage is not included in this packaging pass;
validation is currently through focused component tests.
> 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, GPT-5.4, local tool-use coding agent with terminal, git,
GitHub connector, GitHub CLI, and Paperclip API access.
## 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
Screenshot note: no before/after screenshots were captured in this PR
packaging pass; the UI changes are covered by focused component tests
listed above.
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 06:01:49 -05:00
|
|
|
goals,
|
[codex] Fix stale issue execution run locks (#4258)
## Thinking Path
> - Paperclip is a control plane for AI-agent companies, so issue
checkout and execution ownership are core safety contracts.
> - The affected subsystem is the issue service and route layer that
gates agent writes by `checkoutRunId` and `executionRunId`.
> - PAP-1982 exposed a stale-lock failure mode where a terminal
heartbeat run could leave `executionRunId` pinned after checkout
ownership had moved or been cleared.
> - That stale execution lock could reject legitimate
PATCH/comment/release requests from the rightful assignee after a
harness restart.
> - This pull request centralizes terminal-run cleanup, applies it
before ownership-gated writes, and adds a board-only recovery endpoint
for operator intervention.
> - The benefit is that crashed or terminal runs no longer strand issues
behind stale execution locks, while live execution locks still block
conflicting writes.
## What Changed
- Added `issueService.clearExecutionRunIfTerminal()` to atomically lock
the issue/run rows and clear terminal or missing execution-run locks.
- Reused stale execution-lock cleanup from checkout,
`assertCheckoutOwner()`, and `release()`.
- Allowed the same assigned agent/current run to adopt an unowned
`in_progress` checkout after stale execution-lock cleanup.
- Updated release to clear `executionRunId`, `executionAgentNameKey`,
and `executionLockedAt`.
- Added board-only `POST /api/issues/:id/admin/force-release` with
company access checks, optional `clearAssignee=true`, and
`issue.admin_force_release` audit logging.
- Added embedded Postgres service tests and route integration tests for
stale-lock recovery, release behavior, and admin force-release
authorization/audit behavior.
- Documented the new force-release API in `doc/SPEC-implementation.md`.
## Verification
- `pnpm vitest run server/src/__tests__/issues-service.test.ts
server/src/__tests__/issue-stale-execution-lock-routes.test.ts` passed.
- `pnpm vitest run
server/src/__tests__/issue-stale-execution-lock-routes.test.ts
server/src/__tests__/approval-routes-idempotency.test.ts
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/issue-telemetry-routes.test.ts` passed.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- `git diff --check` passed.
- `pnpm lint` could not run because this repo has no `lint` command.
- Full `pnpm test:run` completed with 4 failures in existing route
suites: `approval-routes-idempotency.test.ts` (2),
`issue-comment-reopen-routes.test.ts` (1), and
`issue-telemetry-routes.test.ts` (1). Those same files pass when run
isolated and when run together with the new stale-lock route test, so
this appears to be a whole-suite ordering/mock-isolation issue outside
this patch path.
## Risks
- Medium: this changes ownership-gated write behavior. The new adoption
path is limited to the current run, the current assignee, `in_progress`
issues, and rows with no checkout owner after terminal-lock cleanup.
- Low: the admin force-release endpoint is board-only and
company-scoped, but misuse can intentionally clear a live lock. It
writes an audit event with prior lock IDs.
- No schema or migration changes.
> 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, GPT-5 coding agent (`gpt-5`), agentic coding with
terminal/tool use and local test execution.
## 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
2026-04-22 10:43:38 -05:00
|
|
|
heartbeatRuns,
|
2026-03-30 14:08:44 -05:00
|
|
|
instanceSettings,
|
2026-03-21 12:20:48 -05:00
|
|
|
issueComments,
|
2026-03-26 08:19:16 -05:00
|
|
|
issueInboxArchives,
|
2026-04-04 13:56:04 -05:00
|
|
|
issueRelations,
|
2026-03-21 12:20:48 -05:00
|
|
|
issues,
|
2026-03-30 14:08:44 -05:00
|
|
|
projectWorkspaces,
|
2026-03-28 22:21:24 -05:00
|
|
|
projects,
|
2026-03-21 12:20:48 -05:00
|
|
|
} from "@paperclipai/db";
|
2026-03-26 11:04:07 -05:00
|
|
|
import {
|
|
|
|
|
getEmbeddedPostgresTestSupport,
|
|
|
|
|
startEmbeddedPostgresTestDatabase,
|
|
|
|
|
} from "./helpers/embedded-postgres.js";
|
2026-03-30 14:08:44 -05:00
|
|
|
import { instanceSettingsService } from "../services/instance-settings.ts";
|
fix(ui): fix message attribution for agent-posted comments with user author IDs (#5780)
## Thinking Path
> - Paperclip’s issue chat is an audit surface: reviewers need to trust
who actually authored a message.
> - Some historical agent comments were persisted with `authorUserId`
and no surviving `createdByRunId`, so the UI rendered real agent output
as if it came from the board user.
> - A pure timestamp-window fallback is too risky because human
reviewers can comment while agents are running.
> - The safe recovery path is to derive attribution only when the server
can prove it from same-issue run logs that include the exact posted
comment id, then let the chat renderer prefer that recovered agent
attribution.
> - This keeps historical threads trustworthy without mutating old
database rows or guessing in ambiguous cases.
## What Changed
- Added shared `IssueComment` fields for derived attribution so server
and UI can carry recovered `derivedAuthorAgentId`,
`derivedCreatedByRunId`, and `derivedAuthorSource` consistently.
- Added server-side attribution recovery in
`server/src/services/issues.ts` that reads same-issue run logs and only
derives agent authorship when a run log contains the exact `comment id:
...` emitted during posting.
- Updated issue chat rendering in `ui/src/lib/issue-chat-messages.ts` to
prefer direct agent authorship, then activity-log `runAgentId`, then the
server-derived attribution.
- Removed the unsafe UI-only run-window fallback from
`ui/src/pages/IssueDetail.tsx` so human comments posted during an active
run are not silently relabeled as agent output.
- Added regression coverage for both the run-log derivation path and the
chat-rendering fallback behavior.
- Bounded server-side run-log enrichment to 8 concurrent reads per
request and removed the unused `issueCommentSchema` declaration during
PR cleanup.
## Verification
- `pnpm exec vitest run ui/src/lib/issue-chat-messages.test.ts
server/src/__tests__/issues-service.test.ts`
- `pnpm test:run:general`
- Live validation on May 12, 2026 in `PAPA-322`: confirmed the
previously misattributed historical comments on `PAPA-316` now render as
Claude-authored on `http://goldie.gerbil-company.ts.net:3100`.
- Reviewer check: open `PAPA-316` in the running instance and confirm
historical comments such as `## Investigation: exe.dev 422 + codex
re-test` render under Claude instead of the board user.
## Risks
- Low risk. The change is scoped to comment attribution recovery and
rendering.
- Derived attribution is intentionally conservative: if there is no
exact run-log proof, the comment remains user-authored instead of
guessing.
- Run-log recovery depends on retained same-issue logs, so older
comments without that evidence remain unchanged.
## Model Used
- OpenAI Codex via the Paperclip `codex_local` adapter (GPT-5-class
coding agent with tool use in the local Paperclip runtime; the exact
deployment/model ID is not surfaced by this workspace).
## 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
- [ ] 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>
2026-05-12 01:20:49 -07:00
|
|
|
import {
|
|
|
|
|
clampIssueListLimit,
|
|
|
|
|
deriveIssueCommentRunLogAttribution,
|
|
|
|
|
ISSUE_LIST_MAX_LIMIT,
|
|
|
|
|
issueService,
|
|
|
|
|
} from "../services/issues.ts";
|
[codex] Split backend control-plane QoL slice (#4700)
## Thinking Path
> - Paperclip is the control plane for autonomous AI companies, so
backend task ownership, recovery, review visibility, and company-scoped
limits need to stay enforceable without UI-only coupling.
> - Closed PR #4692 bundled those backend changes with UI workflow,
docs, skills, workflow, and lockfile churn.
> - PAP-2694 asks for a clean backend/control-plane slice from that
closed branch.
> - This branch starts from current `master` and mines only the `cli`,
`packages/db`, `packages/shared`, and `server` contracts/tests needed
for the backend behavior.
> - It explicitly excludes UI workflow/performance work,
`.github/workflows/pr.yml`, `pnpm-lock.yaml`, docs, skills,
package-script, adapter UI build-config, and perf fixture script
changes; the only UI files are fixture/test updates required by the
tightened shared `Company` contract.
> - The benefit is a smaller reviewable PR that preserves the
control-plane fixes while staying under Greptile s 100-file review
limit.
## What Changed
- Added company-scoped attachment-size limits through DB
schema/migrations, shared company portability contracts, CLI
import/export coverage, and server attachment upload enforcement.
- Added productivity review service/API behavior for no-comment streak,
long-active, and high-churn review issues, including request-depth
clamping and issue summary exposure.
- Hardened issue ownership and recovery/control-plane paths: peer-agent
mutation denial, issue tree pause/resume behavior, stranded recovery
origins, and related activity/test coverage.
- Preserved related backend contract updates for routine timestamp
variables and managed agent instruction bundles because they live in
shared/server contracts from the source branch.
- Addressed Greptile feedback by making `Company.attachmentMaxBytes`
non-optional, simplifying review request-depth clamping, fixing the
migration final newline, and enforcing the process-level attachment cap
as the final ceiling for uploads.
- Added minimal company fixtures needed for repo-wide typecheck/build
and kept the PR to 66 changed files with forbidden/non-slice paths
excluded.
## Verification
- `pnpm install --frozen-lockfile`
- `git diff --check origin/master..HEAD`
- `git diff --name-only origin/master..HEAD | wc -l` -> 66 files
- `git diff --name-only origin/master..HEAD -- .github/workflows/pr.yml
pnpm-lock.yaml package.json doc skills .agents scripts
packages/adapters` -> no output
- `pnpm exec vitest run --config vitest.config.ts
packages/shared/src/validators/issue.test.ts
packages/shared/src/routine-variables.test.ts
packages/shared/src/adapter-types.test.ts
cli/src/__tests__/company-import-export-e2e.test.ts
cli/src/__tests__/company.test.ts
server/src/__tests__/productivity-review-service.test.ts
server/src/__tests__/issue-tree-control-service.test.ts
server/src/__tests__/issue-tree-control-routes.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/issue-attachment-routes.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts
server/src/__tests__/issues-service.test.ts` -> 12 files, 147 tests
passed
- `pnpm exec vitest run --config vitest.config.ts
cli/src/__tests__/company-delete.test.ts
cli/src/__tests__/company-import-export-e2e.test.ts
server/src/__tests__/productivity-review-service.test.ts` -> 3 files, 18
tests passed
- `pnpm exec vitest run --config vitest.config.ts
server/src/__tests__/issue-attachment-routes.test.ts` -> 1 file, 6 tests
passed
- `pnpm --filter @paperclipai/db typecheck && pnpm --filter
@paperclipai/shared typecheck && pnpm --filter @paperclipai/server
typecheck && pnpm --filter paperclipai typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck && pnpm --filter
@paperclipai/ui build`
## Risks
- Includes migrations `0073_shiny_salo.sql` and
`0074_striped_genesis.sql`; merge ordering matters if another PR adds
migrations first.
- This is intentionally backend-only apart from fixture/test updates
forced by shared type correctness; UI affordances from PR #4692 are not
present here and should land in separate UI slices.
- The worktree install emitted plugin SDK bin-link warnings for unbuilt
plugin packages, but the targeted tests and package typechecks completed
successfully.
> 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, GPT-5 coding agent, tool-enabled terminal/GitHub
workflow. Exact runtime context window was not exposed by the harness.
## 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>
2026-04-28 16:46:45 -05:00
|
|
|
import { buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared";
|
2026-03-21 12:20:48 -05:00
|
|
|
|
2026-03-26 11:04:07 -05:00
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
|
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
2026-03-21 12:20:48 -05:00
|
|
|
|
[codex] Improve agent runtime recovery and governance (#4086)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - The heartbeat runtime, agent import path, and agent configuration
defaults determine whether work is dispatched safely and predictably.
> - Several accumulated fixes all touched agent execution recovery, wake
routing, import behavior, and runtime concurrency defaults.
> - Those changes need to land together so the heartbeat service and
agent creation defaults stay internally consistent.
> - This pull request groups the runtime/governance changes from the
split branch into one standalone branch.
> - The benefit is safer recovery for stranded runs, bounded high-volume
reads, imported-agent approval correctness, skill-template support, and
a clearer default concurrency policy.
## What Changed
- Fixed stranded continuation recovery so successful automatic retries
are requeued instead of incorrectly blocking the issue.
- Bounded high-volume issue/log reads across issue, heartbeat, agent,
project, and workspace paths.
- Fixed imported-agent approval and instruction-path permission
handling.
- Quarantined seeded worktree execution state during worktree
provisioning.
- Queued approval follow-up wakes and hardened SQL_ASCII heartbeat
output handling.
- Added reusable agent instruction templates for hiring flows.
- Set the default max concurrent agent runs to five and updated related
UI/tests/docs.
## Verification
- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run server/src/__tests__/company-portability.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts
server/src/__tests__/heartbeat-comment-wake-batching.test.ts
server/src/__tests__/heartbeat-list.test.ts
server/src/__tests__/issues-service.test.ts
server/src/__tests__/agent-permissions-routes.test.ts
packages/adapter-utils/src/server-utils.test.ts
ui/src/lib/new-agent-runtime-config.test.ts`
- Split integration check: merged this branch first, followed by the
other [PAP-1614](/PAP/issues/PAP-1614) branches, with no merge
conflicts.
- Confirmed this branch does not include `pnpm-lock.yaml`.
## Risks
- Medium risk: touches heartbeat recovery, queueing, and issue list
bounds in central runtime paths.
- Imported-agent and concurrency default behavior changes may affect
existing automation that assumes one-at-a-time default runs.
- No database migrations are included.
> 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, GPT-5.4 tool-enabled coding model, agentic
code-editing/runtime with local shell and GitHub CLI access; exact
context window and reasoning mode are not exposed by the Paperclip
harness.
## 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>
2026-04-20 06:19:48 -05:00
|
|
|
describe("issue list limit helpers", () => {
|
|
|
|
|
it("clamps untrusted issue-list limits to the server maximum", () => {
|
|
|
|
|
expect(clampIssueListLimit(0)).toBe(1);
|
|
|
|
|
expect(clampIssueListLimit(25.9)).toBe(25);
|
|
|
|
|
expect(clampIssueListLimit(ISSUE_LIST_MAX_LIMIT + 10)).toBe(ISSUE_LIST_MAX_LIMIT);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
fix(ui): fix message attribution for agent-posted comments with user author IDs (#5780)
## Thinking Path
> - Paperclip’s issue chat is an audit surface: reviewers need to trust
who actually authored a message.
> - Some historical agent comments were persisted with `authorUserId`
and no surviving `createdByRunId`, so the UI rendered real agent output
as if it came from the board user.
> - A pure timestamp-window fallback is too risky because human
reviewers can comment while agents are running.
> - The safe recovery path is to derive attribution only when the server
can prove it from same-issue run logs that include the exact posted
comment id, then let the chat renderer prefer that recovered agent
attribution.
> - This keeps historical threads trustworthy without mutating old
database rows or guessing in ambiguous cases.
## What Changed
- Added shared `IssueComment` fields for derived attribution so server
and UI can carry recovered `derivedAuthorAgentId`,
`derivedCreatedByRunId`, and `derivedAuthorSource` consistently.
- Added server-side attribution recovery in
`server/src/services/issues.ts` that reads same-issue run logs and only
derives agent authorship when a run log contains the exact `comment id:
...` emitted during posting.
- Updated issue chat rendering in `ui/src/lib/issue-chat-messages.ts` to
prefer direct agent authorship, then activity-log `runAgentId`, then the
server-derived attribution.
- Removed the unsafe UI-only run-window fallback from
`ui/src/pages/IssueDetail.tsx` so human comments posted during an active
run are not silently relabeled as agent output.
- Added regression coverage for both the run-log derivation path and the
chat-rendering fallback behavior.
- Bounded server-side run-log enrichment to 8 concurrent reads per
request and removed the unused `issueCommentSchema` declaration during
PR cleanup.
## Verification
- `pnpm exec vitest run ui/src/lib/issue-chat-messages.test.ts
server/src/__tests__/issues-service.test.ts`
- `pnpm test:run:general`
- Live validation on May 12, 2026 in `PAPA-322`: confirmed the
previously misattributed historical comments on `PAPA-316` now render as
Claude-authored on `http://goldie.gerbil-company.ts.net:3100`.
- Reviewer check: open `PAPA-316` in the running instance and confirm
historical comments such as `## Investigation: exe.dev 422 + codex
re-test` render under Claude instead of the board user.
## Risks
- Low risk. The change is scoped to comment attribution recovery and
rendering.
- Derived attribution is intentionally conservative: if there is no
exact run-log proof, the comment remains user-authored instead of
guessing.
- Run-log recovery depends on retained same-issue logs, so older
comments without that evidence remain unchanged.
## Model Used
- OpenAI Codex via the Paperclip `codex_local` adapter (GPT-5-class
coding agent with tool use in the local Paperclip runtime; the exact
deployment/model ID is not surfaced by this workspace).
## 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
- [ ] 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>
2026-05-12 01:20:49 -07:00
|
|
|
describe("deriveIssueCommentRunLogAttribution", () => {
|
|
|
|
|
it("recovers agent attribution from run logs that printed the posted comment id", () => {
|
|
|
|
|
const commentId = randomUUID();
|
|
|
|
|
const runId = randomUUID();
|
|
|
|
|
const agentId = randomUUID();
|
|
|
|
|
|
|
|
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
id: commentId,
|
|
|
|
|
authorAgentId: null,
|
|
|
|
|
authorUserId: "user-1",
|
|
|
|
|
createdByRunId: null,
|
|
|
|
|
createdAt: new Date("2026-05-11T18:55:40.090Z"),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
runId,
|
|
|
|
|
agentId,
|
|
|
|
|
createdAt: new Date("2026-05-11T18:51:56.246Z"),
|
|
|
|
|
startedAt: new Date("2026-05-11T18:51:56.257Z"),
|
|
|
|
|
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
|
|
|
|
|
logContent: `comment id: ${commentId}\n`,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(derived.get(commentId)).toEqual({
|
|
|
|
|
derivedAuthorAgentId: agentId,
|
|
|
|
|
derivedCreatedByRunId: runId,
|
|
|
|
|
derivedAuthorSource: "run_log_comment_post",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not rewrite comments without exact run-log proof", () => {
|
|
|
|
|
const commentId = randomUUID();
|
|
|
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
id: commentId,
|
|
|
|
|
authorAgentId: null,
|
|
|
|
|
authorUserId: "user-1",
|
|
|
|
|
createdByRunId: null,
|
|
|
|
|
createdAt: new Date("2026-05-11T18:55:40.090Z"),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
runId: randomUUID(),
|
|
|
|
|
agentId: randomUUID(),
|
|
|
|
|
createdAt: new Date("2026-05-11T18:51:56.246Z"),
|
|
|
|
|
startedAt: new Date("2026-05-11T18:51:56.257Z"),
|
|
|
|
|
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
|
|
|
|
|
logContent: "posted results without echoing the comment id",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(derived.has(commentId)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-04 13:56:04 -05:00
|
|
|
async function ensureIssueRelationsTable(db: ReturnType<typeof createDb>) {
|
|
|
|
|
await db.execute(sql.raw(`
|
|
|
|
|
CREATE TABLE IF NOT EXISTS "issue_relations" (
|
|
|
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
|
"company_id" uuid NOT NULL,
|
|
|
|
|
"issue_id" uuid NOT NULL,
|
|
|
|
|
"related_issue_id" uuid NOT NULL,
|
|
|
|
|
"type" text NOT NULL,
|
|
|
|
|
"created_by_agent_id" uuid,
|
|
|
|
|
"created_by_user_id" text,
|
|
|
|
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
|
|
|
"updated_at" timestamptz NOT NULL DEFAULT now()
|
|
|
|
|
);
|
|
|
|
|
`));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 11:04:07 -05:00
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
|
|
|
console.warn(
|
|
|
|
|
`Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
|
|
|
);
|
2026-03-21 12:20:48 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-26 11:04:07 -05:00
|
|
|
describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
2026-03-21 12:20:48 -05:00
|
|
|
let db!: ReturnType<typeof createDb>;
|
|
|
|
|
let svc!: ReturnType<typeof issueService>;
|
2026-03-26 11:04:07 -05:00
|
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
2026-03-21 12:20:48 -05:00
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
2026-03-26 11:04:07 -05:00
|
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-");
|
|
|
|
|
db = createDb(tempDb.connectionString);
|
2026-03-21 12:20:48 -05:00
|
|
|
svc = issueService(db);
|
2026-04-04 13:56:04 -05:00
|
|
|
await ensureIssueRelationsTable(db);
|
2026-03-21 12:20:48 -05:00
|
|
|
}, 20_000);
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await db.delete(issueComments);
|
2026-04-04 13:56:04 -05:00
|
|
|
await db.delete(issueRelations);
|
2026-03-26 08:19:16 -05:00
|
|
|
await db.delete(issueInboxArchives);
|
2026-03-21 12:20:48 -05:00
|
|
|
await db.delete(activityLog);
|
|
|
|
|
await db.delete(issues);
|
2026-03-28 22:21:24 -05:00
|
|
|
await db.delete(executionWorkspaces);
|
2026-03-30 14:08:44 -05:00
|
|
|
await db.delete(projectWorkspaces);
|
2026-03-28 22:21:24 -05:00
|
|
|
await db.delete(projects);
|
[codex] Add run liveness continuations (#4083)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - Heartbeat runs are the control-plane record of each agent execution
window.
> - Long-running local agents can exhaust context or stop while still
holding useful next-step state.
> - Operators need that stop reason, next action, and continuation path
to be durable and visible.
> - This pull request adds run liveness metadata, continuation
summaries, and UI surfaces for issue run ledgers.
> - The benefit is that interrupted or long-running work can resume with
clearer context instead of losing the agent's last useful handoff.
## What Changed
- Added heartbeat-run liveness fields, continuation attempt tracking,
and an idempotent `0058` migration.
- Added server services and tests for run liveness, continuation
summaries, stop metadata, and activity backfill.
- Wired local and HTTP adapters to surface continuation/liveness context
through shared adapter utilities.
- Added shared constants, validators, and heartbeat types for liveness
continuation state.
- Added issue-detail UI surfaces for continuation handoffs and the run
ledger, with component tests.
- Updated agent runtime docs, heartbeat protocol docs, prompt guidance,
onboarding assets, and skills instructions to explain continuation
behavior.
- Addressed Greptile feedback by scoping document evidence by run,
excluding system continuation-summary documents from liveness evidence,
importing shared liveness types, surfacing hidden ledger run counts,
documenting bounded retry behavior, and moving run-ledger liveness
backfill off the request path.
## Verification
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/run-continuations.test.ts
server/src/__tests__/run-liveness.test.ts
server/src/__tests__/activity-service.test.ts
server/src/__tests__/documents-service.test.ts
server/src/__tests__/issue-continuation-summary.test.ts
server/src/services/heartbeat-stop-metadata.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/components/IssueContinuationHandoff.test.tsx
ui/src/components/IssueDocumentsSection.test.tsx`
- `pnpm --filter @paperclipai/db build`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/run-continuations.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "treats a
plan document update"`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts -t "activity
service|treats a plan document update"`
- Remote PR checks on head `e53b1a1d`: `verify`, `e2e`, `policy`, and
Snyk all passed.
- Confirmed `public-gh/master` is an ancestor of this branch after
fetching `public-gh master`.
- Confirmed `pnpm-lock.yaml` is not included in the branch diff.
- Confirmed migration `0058_wealthy_starbolt.sql` is ordered after
`0057` and uses `IF NOT EXISTS` guards for repeat application.
- Greptile inline review threads are resolved.
## Risks
- Medium risk: this touches heartbeat execution, liveness recovery,
activity rendering, issue routes, shared contracts, docs, and UI.
- Migration risk is mitigated by additive columns/indexes and idempotent
guards.
- Run-ledger liveness backfill is now asynchronous, so the first ledger
response can briefly show historical missing liveness until the
background backfill completes.
- UI screenshot coverage is not included in this packaging pass;
validation is currently through focused component tests.
> 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, GPT-5.4, local tool-use coding agent with terminal, git,
GitHub connector, GitHub CLI, and Paperclip API access.
## 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
Screenshot note: no before/after screenshots were captured in this PR
packaging pass; the UI changes are covered by focused component tests
listed above.
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 06:01:49 -05:00
|
|
|
await db.delete(goals);
|
2026-05-13 12:56:51 -05:00
|
|
|
await db.delete(heartbeatRuns);
|
2026-03-21 12:20:48 -05:00
|
|
|
await db.delete(agents);
|
2026-03-30 14:08:44 -05:00
|
|
|
await db.delete(instanceSettings);
|
2026-03-21 12:20:48 -05:00
|
|
|
await db.delete(companies);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
2026-03-26 11:04:07 -05:00
|
|
|
await tempDb?.cleanup();
|
2026-03-21 12:20:48 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns issues an agent participated in across the supported signals", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const agentId = randomUUID();
|
|
|
|
|
const otherAgentId = 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: agentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "CodexCoder",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
permissions: {},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: otherAgentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "OtherAgent",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
permissions: {},
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const assignedIssueId = randomUUID();
|
|
|
|
|
const createdIssueId = randomUUID();
|
|
|
|
|
const commentedIssueId = randomUUID();
|
|
|
|
|
const activityIssueId = randomUUID();
|
|
|
|
|
const excludedIssueId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: assignedIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Assigned issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
createdByAgentId: otherAgentId,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: createdIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Created issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByAgentId: agentId,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: commentedIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Commented issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByAgentId: otherAgentId,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: activityIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Activity issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByAgentId: otherAgentId,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: excludedIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Excluded issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByAgentId: otherAgentId,
|
|
|
|
|
assigneeAgentId: otherAgentId,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(issueComments).values({
|
|
|
|
|
companyId,
|
|
|
|
|
issueId: commentedIssueId,
|
|
|
|
|
authorAgentId: agentId,
|
|
|
|
|
body: "Investigating this issue.",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(activityLog).values({
|
|
|
|
|
companyId,
|
|
|
|
|
actorType: "agent",
|
|
|
|
|
actorId: agentId,
|
|
|
|
|
action: "issue.updated",
|
|
|
|
|
entityType: "issue",
|
|
|
|
|
entityId: activityIssueId,
|
|
|
|
|
agentId,
|
|
|
|
|
details: { changed: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await svc.list(companyId, { participantAgentId: agentId });
|
|
|
|
|
const resultIds = new Set(result.map((issue) => issue.id));
|
|
|
|
|
|
|
|
|
|
expect(resultIds).toEqual(new Set([
|
|
|
|
|
assignedIssueId,
|
|
|
|
|
createdIssueId,
|
|
|
|
|
commentedIssueId,
|
|
|
|
|
activityIssueId,
|
|
|
|
|
]));
|
|
|
|
|
expect(resultIds.has(excludedIssueId)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("combines participation filtering with search", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const agentId = 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: agentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "CodexCoder",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
permissions: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const matchedIssueId = randomUUID();
|
|
|
|
|
const otherIssueId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: matchedIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Invoice reconciliation",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByAgentId: agentId,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: otherIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Weekly planning",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByAgentId: agentId,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const result = await svc.list(companyId, {
|
|
|
|
|
participantAgentId: agentId,
|
|
|
|
|
q: "invoice",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
|
|
|
|
});
|
2026-03-26 08:19:16 -05:00
|
|
|
|
2026-04-06 20:30:50 -05:00
|
|
|
it("applies result limits to issue search", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const exactIdentifierId = randomUUID();
|
|
|
|
|
const titleMatchId = randomUUID();
|
|
|
|
|
const descriptionMatchId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: exactIdentifierId,
|
|
|
|
|
companyId,
|
|
|
|
|
issueNumber: 42,
|
|
|
|
|
identifier: "PAP-42",
|
|
|
|
|
title: "Completely unrelated",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: titleMatchId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Search ranking issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: descriptionMatchId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Another item",
|
|
|
|
|
description: "Contains the search keyword",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const result = await svc.list(companyId, {
|
|
|
|
|
q: "search",
|
|
|
|
|
limit: 2,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]);
|
|
|
|
|
});
|
|
|
|
|
|
[codex] Runtime control-plane fixes (#6380)
## Thinking Path
> - Paperclip orchestrates AI agents through a server-side control plane
> - That control plane depends on reliable issue state transitions,
plugin lifecycle behavior, import limits, and startup/shutdown handling
> - Several small runtime fixes had accumulated on the working branch
and were mixed with larger feature work
> - Keeping them separate makes the correctness fixes reviewable and
mergeable without waiting for cloud-sync UI work
> - This pull request groups the server/runtime control-plane fixes into
one standalone branch
> - The benefit is a tighter, safer runtime baseline for retries,
imports, plugin migrations, feedback flushing, and trusted cloud import
handling
## What Changed
- Fixed updated issue list pagination sorting and scheduled retry
comment handling.
- Re-applied pending plugin migrations during hot reload and fixed
plugin-schema worktree seed restore.
- Hardened public tenant DB startup, portable import body limits,
trusted cloud import errors, and trusted cloud tenant import mutation
access.
- Expired stale request confirmations after user comments.
- Added feedback export shutdown hardening so database-unavailable flush
loops stop cleanly.
- Guarded plugin worker `error` event emission when no listener is
registered.
## Verification
- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm --filter @paperclipai/plugin-sdk build`
- `npm run install --prefix
node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3`
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts
server/src/__tests__/plugin-lifecycle-restart.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/body-limits.test.ts
server/src/__tests__/feedback-flush-controller.test.ts
server/src/__tests__/error-handler.test.ts
server/src/__tests__/board-mutation-guard.test.ts
packages/db/src/backup-lib.test.ts` initially exposed local setup issues
and two 5s test timeouts.
- Rerun after local prereq build: `pnpm exec vitest run --testTimeout
15000 server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/feedback-flush-controller.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts` passed.
- Some embedded Postgres-backed tests skipped on this host because local
Postgres init was unavailable.
## Risks
- Runtime-touching branch: startup/shutdown and issue interaction
behavior should be reviewed carefully.
- The feedback export change disables repeated flush attempts only for
database connection-refused failures; other upload failures still log
normally.
- The plugin worker error guard avoids process crashes from unhandled
EventEmitter errors but may hide errors from code paths that expected an
emitted listener.
> 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, GPT-5-based coding agent with local shell/git/tool use.
Exact hosted model ID and context-window size are not exposed by the
local Paperclip adapter runtime.
## 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>
2026-05-20 10:37:11 -05:00
|
|
|
it("can page issues by most recently updated before priority", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const oldCriticalIssueId = randomUUID();
|
|
|
|
|
const recentMediumIssueId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: oldCriticalIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Old critical issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "critical",
|
|
|
|
|
updatedAt: new Date("2026-05-01T10:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: recentMediumIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Recent medium issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
updatedAt: new Date("2026-05-17T21:12:29.993Z"),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const result = await svc.list(companyId, {
|
|
|
|
|
limit: 1,
|
|
|
|
|
sortField: "updated",
|
|
|
|
|
sortDir: "desc",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([recentMediumIssueId]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-11 06:57:49 -05:00
|
|
|
it("ranks comment matches ahead of description-only matches", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const commentMatchId = randomUUID();
|
|
|
|
|
const descriptionMatchId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: commentMatchId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Comment match",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: descriptionMatchId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Description match",
|
|
|
|
|
description: "Contains pull/3303 in the description",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(issueComments).values({
|
|
|
|
|
companyId,
|
|
|
|
|
issueId: commentMatchId,
|
|
|
|
|
body: "Reference: https://github.com/paperclipai/paperclip/pull/3303",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await svc.list(companyId, {
|
|
|
|
|
q: "pull/3303",
|
|
|
|
|
limit: 2,
|
|
|
|
|
includeRoutineExecutions: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([commentMatchId, descriptionMatchId]);
|
|
|
|
|
});
|
|
|
|
|
|
[codex] Improve issue thread review flow (#4381)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Issue detail is where operators coordinate review, approvals, and
follow-up work with active runs
> - That thread UI needs to surface blockers, descendants, review
handoffs, and reply ergonomics clearly enough for humans to guide agent
work
> - Several small gaps in the issue-thread flow were making review and
navigation clunkier than necessary
> - This pull request improves the reply composer, descendant/blocker
presentation, interaction folding, and review-request handoff plumbing
together as one cohesive issue-thread workflow slice
> - The benefit is a cleaner operator review loop without changing the
broader task model
## What Changed
- restored and refined the floating reply composer behavior in the issue
thread
- folded expired confirmation interactions and improved post-submit
thread scrolling behavior
- surfaced descendant issue context and inline blocker/paused-assignee
notices on the issue detail view
- tightened large-board first paint behavior in `IssuesList`
- added loose review-request handoffs through the issue
execution-policy/update path and covered them with tests
## Verification
- `pnpm vitest run ui/src/pages/IssueDetail.test.tsx`
- `pnpm vitest run server/src/__tests__/issues-service.test.ts
server/src/__tests__/issue-execution-policy.test.ts`
- `pnpm exec vitest run --project @paperclipai/ui
ui/src/components/IssueChatThread.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/IssuesList.test.tsx ui/src/lib/issue-tree.test.ts
ui/src/api/issues.test.ts`
- `pnpm exec vitest run --project @paperclipai/adapter-utils
packages/adapter-utils/src/server-utils.test.ts`
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/issue-comment-reopen-routes.test.ts -t "coerces
executor handoff patches into workflow-controlled review wakes|wakes the
return assignee with execution_changes_requested"`
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/issue-execution-policy.test.ts
server/src/__tests__/issues-service.test.ts`
## Visual Evidence
- UI layout changes are covered by the focused issue-thread component
and issue-detail tests listed above. Browser screenshots were not
attachable from this automated greploop environment, so reviewers should
use the running preview for final visual confirmation.
## Risks
- Moderate UI-flow risk: these changes touch the issue detail experience
in multiple spots, so regressions would most likely show up as
thread-layout quirks or incorrect review-handoff behavior
> 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 GPT-5-based coding agent with tool use and code execution
in the Codex CLI environment
## 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 or documented the visual verification path
- [ ] 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>
2026-04-24 08:02:45 -05:00
|
|
|
it("filters issue lists to the full descendant tree for a root issue", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const rootId = randomUUID();
|
|
|
|
|
const childId = randomUUID();
|
|
|
|
|
const grandchildId = randomUUID();
|
|
|
|
|
const siblingId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: rootId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Root",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: childId,
|
|
|
|
|
companyId,
|
|
|
|
|
parentId: rootId,
|
|
|
|
|
title: "Child",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: grandchildId,
|
|
|
|
|
companyId,
|
|
|
|
|
parentId: childId,
|
|
|
|
|
title: "Grandchild",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: siblingId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Sibling",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const result = await svc.list(companyId, { descendantOf: rootId });
|
|
|
|
|
|
|
|
|
|
expect(new Set(result.map((issue) => issue.id))).toEqual(new Set([childId, grandchildId]));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("combines descendant filtering with search", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const rootId = randomUUID();
|
|
|
|
|
const childId = randomUUID();
|
|
|
|
|
const grandchildId = randomUUID();
|
|
|
|
|
const outsideMatchId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: rootId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Root",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: childId,
|
|
|
|
|
companyId,
|
|
|
|
|
parentId: rootId,
|
|
|
|
|
title: "Relevant parent",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: grandchildId,
|
|
|
|
|
companyId,
|
|
|
|
|
parentId: childId,
|
|
|
|
|
title: "Needle grandchild",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: outsideMatchId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Needle outside",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const result = await svc.list(companyId, { descendantOf: rootId, q: "needle" });
|
|
|
|
|
|
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([grandchildId]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-04 13:20:58 -05:00
|
|
|
it("accepts issue identifiers with alphanumeric prefixes through getById", async () => {
|
2026-04-02 09:11:49 -05:00
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const issueId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
2026-05-04 13:20:58 -05:00
|
|
|
issuePrefix: "PC1A2",
|
2026-04-02 09:11:49 -05:00
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: issueId,
|
|
|
|
|
companyId,
|
|
|
|
|
issueNumber: 1064,
|
2026-05-04 13:20:58 -05:00
|
|
|
identifier: "PC1A2-1064",
|
2026-04-02 09:11:49 -05:00
|
|
|
title: "Feedback votes error",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByUserId: "user-1",
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-04 13:20:58 -05:00
|
|
|
const issue = await svc.getById("pc1a2-1064");
|
2026-04-02 09:11:49 -05:00
|
|
|
|
|
|
|
|
expect(issue).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
id: issueId,
|
2026-05-04 13:20:58 -05:00
|
|
|
identifier: "PC1A2-1064",
|
2026-04-02 09:11:49 -05:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns null instead of throwing for malformed non-uuid issue refs", async () => {
|
|
|
|
|
await expect(svc.getById("not-a-uuid")).resolves.toBeNull();
|
|
|
|
|
});
|
2026-03-28 22:21:24 -05:00
|
|
|
it("filters issues by execution workspace id", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const targetWorkspaceId = randomUUID();
|
|
|
|
|
const otherWorkspaceId = randomUUID();
|
|
|
|
|
const linkedIssueId = randomUUID();
|
|
|
|
|
const otherLinkedIssueId = randomUUID();
|
|
|
|
|
const unlinkedIssueId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(executionWorkspaces).values([
|
|
|
|
|
{
|
|
|
|
|
id: targetWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
mode: "shared_workspace",
|
|
|
|
|
strategyType: "project_primary",
|
|
|
|
|
name: "Target workspace",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "local_fs",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: otherWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
mode: "shared_workspace",
|
|
|
|
|
strategyType: "project_primary",
|
|
|
|
|
name: "Other workspace",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "local_fs",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: linkedIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
title: "Linked issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
executionWorkspaceId: targetWorkspaceId,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: otherLinkedIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
title: "Other linked issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
executionWorkspaceId: otherWorkspaceId,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: unlinkedIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
title: "Unlinked issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const result = await svc.list(companyId, { executionWorkspaceId: targetWorkspaceId });
|
|
|
|
|
|
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([linkedIssueId]);
|
|
|
|
|
});
|
|
|
|
|
|
[codex] Harden heartbeat scheduling and runtime controls (#4223)
## Thinking Path
> - Paperclip orchestrates AI agents through issue checkout, heartbeat
runs, routines, and auditable control-plane state
> - The runtime path has to recover from lost local processes, transient
adapter failures, blocked dependencies, and routine coalescing without
stranding work
> - The existing branch carried several reliability fixes across
heartbeat scheduling, issue runtime controls, routine dispatch, and
operator-facing run state
> - These changes belong together because they share backend contracts,
migrations, and runtime status semantics
> - This pull request groups the control-plane/runtime slice so it can
merge independently from board UI polish and adapter sandbox work
> - The benefit is safer heartbeat recovery, clearer runtime controls,
and more predictable recurring execution behavior
## What Changed
- Adds bounded heartbeat retry scheduling, scheduled retry state, and
Codex transient failure recovery handling.
- Tightens heartbeat process recovery, blocker wake behavior, issue
comment wake handling, routine dispatch coalescing, and
activity/dashboard bounds.
- Adds runtime-control MCP tools and Paperclip skill docs for issue
workspace runtime management.
- Adds migrations `0061_lively_thor_girl.sql` and
`0062_routine_run_dispatch_fingerprint.sql`.
- Surfaces retry state in run ledger/agent UI and keeps related shared
types synchronized.
## Verification
- `pnpm exec vitest run
server/src/__tests__/heartbeat-retry-scheduling.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts
server/src/__tests__/routines-service.test.ts`
- `pnpm exec vitest run src/tools.test.ts` from `packages/mcp-server`
## Risks
- Medium risk: this touches heartbeat recovery and routine dispatch,
which are central execution paths.
- Migration order matters if split branches land out of order: merge
this PR before branches that assume the new runtime/routine fields.
- Runtime retry behavior should be watched in CI and in local operator
smoke tests because it changes how transient failures are resumed.
> 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, GPT-5-based coding agent runtime, shell/git tool use
enabled. Exact hosted model build and context window are not exposed in
this Paperclip heartbeat environment.
## 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
- [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
2026-04-21 12:24:11 -05:00
|
|
|
it("filters issues by generic workspace id across execution and project workspace links", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const projectWorkspaceId = randomUUID();
|
|
|
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
|
const executionLinkedIssueId = randomUUID();
|
|
|
|
|
const projectLinkedIssueId = randomUUID();
|
|
|
|
|
const otherIssueId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Feature workspace",
|
|
|
|
|
sourceType: "local_path",
|
|
|
|
|
visibility: "default",
|
|
|
|
|
isPrimary: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
|
|
|
id: executionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
strategyType: "git_worktree",
|
|
|
|
|
name: "Execution workspace",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "git_worktree",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: executionLinkedIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
title: "Execution linked issue",
|
|
|
|
|
status: "done",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
executionWorkspaceId,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: projectLinkedIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
title: "Project linked issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: otherIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
title: "Other issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const executionResult = await svc.list(companyId, { workspaceId: executionWorkspaceId });
|
|
|
|
|
const projectResult = await svc.list(companyId, { workspaceId: projectWorkspaceId });
|
|
|
|
|
|
|
|
|
|
expect(executionResult.map((issue) => issue.id)).toEqual([executionLinkedIssueId]);
|
|
|
|
|
expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort());
|
|
|
|
|
});
|
|
|
|
|
|
Expand plugin host surface (#5205)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin system is the extension boundary for optional product
capabilities
> - Rich plugins need more than a worker entrypoint: they need scoped
database storage, local project folders, managed agents/routines, host
navigation, and reusable UI components
> - The LLM Wiki work exposed those missing host surfaces while keeping
plugin code outside the core control plane
> - This pull request expands the core plugin host, SDK, server APIs,
and UI bridge so plugins can declare and use those surfaces
> - The benefit is that future plugins can integrate with Paperclip
through documented, validated contracts instead of bespoke server or UI
imports
## What Changed
- Added plugin-managed database namespaces and migration tracking,
including Drizzle schema/migration files and SQL validation for
namespace isolation.
- Added server support for plugin local folders, managed agents, managed
routines, scoped plugin APIs, and plugin operation visibility.
- Expanded shared plugin manifest/types/validators and SDK
host/testing/UI exports for richer plugin surfaces.
- Added reusable UI pieces for file trees, managed routines, resizable
sidebars, route sidebars, and plugin bridge initialization.
- Updated plugin docs and example plugins to use the expanded host and
SDK surface.
## Verification
- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/validators/plugin.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/__tests__/plugin-local-folders.test.ts
server/src/__tests__/plugin-managed-agents.test.ts
server/src/__tests__/plugin-managed-routines.test.ts
server/src/__tests__/plugin-orchestration-apis.test.ts
ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx
ui/src/components/ResizableSidebarPane.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed:
11 files, 67 tests.
- Confirmed this PR changes 89 files and does not include
`pnpm-lock.yaml` or `.github/workflows/*`.
## Risks
- Medium: this expands plugin host contracts across db/shared/server/ui
and includes a new core migration (`0076_useful_elektra.sql`).
- The plugin database namespace validator is intentionally restrictive;
plugin authors may need follow-up affordances for SQL patterns that
remain blocked.
- Merge this before the LLM Wiki plugin PR so the plugin can resolve the
new SDK and host APIs.
> 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, GPT-5 coding agent, tool-enabled shell/git/GitHub
workflow. Context window size was not exposed by the runtime.
## 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>
2026-05-05 07:42:57 -05:00
|
|
|
it("hides plugin operation issues from default lists and inbox-style filters while preserving explicit retrieval", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const agentId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const normalIssueId = randomUUID();
|
|
|
|
|
const pluginVisibleIssueId = randomUUID();
|
|
|
|
|
const operationIssueId = 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: agentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Plugin Runner",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
permissions: {},
|
|
|
|
|
});
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Plugin operations",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: normalIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Normal issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: pluginVisibleIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Plugin-visible issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
originKind: "plugin:paperclip.missions:feature",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: operationIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
title: "Plugin operation issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
originKind: "plugin:paperclip.missions:operation",
|
|
|
|
|
originId: "mission-alpha:operation-1",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
|
|
|
|
|
expect(defaultIssueIds).toContain(normalIssueId);
|
|
|
|
|
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
|
|
|
|
|
expect(defaultIssueIds).not.toContain(operationIssueId);
|
|
|
|
|
|
|
|
|
|
const inboxIssueIds = (await svc.list(companyId, {
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
status: "todo,in_progress,blocked",
|
|
|
|
|
includeRoutineExecutions: true,
|
|
|
|
|
})).map((issue) => issue.id);
|
|
|
|
|
expect(inboxIssueIds).toContain(normalIssueId);
|
|
|
|
|
expect(inboxIssueIds).not.toContain(operationIssueId);
|
|
|
|
|
|
|
|
|
|
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
|
|
|
|
|
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
|
|
|
|
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
|
|
|
|
|
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
|
|
|
|
|
|
|
|
|
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
|
|
|
|
|
expect(projectIssueIds).toContain(operationIssueId);
|
|
|
|
|
|
|
|
|
|
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
|
|
|
|
|
expect(advancedIssueIds).toContain(operationIssueId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("excludes plugin operation issues from unread inbox counts", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const userId = "board-user";
|
|
|
|
|
const otherUserId = "other-user";
|
|
|
|
|
const normalIssueId = randomUUID();
|
|
|
|
|
const operationIssueId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: normalIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Normal touched issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByUserId: userId,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: operationIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Plugin operation touched issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByUserId: userId,
|
|
|
|
|
originKind: "plugin:paperclip.missions:operation",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
await db.insert(issueComments).values([
|
|
|
|
|
{
|
|
|
|
|
companyId,
|
|
|
|
|
issueId: normalIssueId,
|
|
|
|
|
authorUserId: otherUserId,
|
|
|
|
|
body: "Unread normal update.",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
companyId,
|
|
|
|
|
issueId: operationIssueId,
|
|
|
|
|
authorUserId: otherUserId,
|
|
|
|
|
body: "Unread operation update.",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await expect(svc.countUnreadTouchedByUser(companyId, userId, "todo")).resolves.toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-26 08:19:16 -05:00
|
|
|
it("hides archived inbox issues until new external activity arrives", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const userId = "user-1";
|
|
|
|
|
const otherUserId = "user-2";
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const visibleIssueId = randomUUID();
|
|
|
|
|
const archivedIssueId = randomUUID();
|
|
|
|
|
const resurfacedIssueId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: visibleIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Visible issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByUserId: userId,
|
|
|
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
|
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: archivedIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Archived issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByUserId: userId,
|
|
|
|
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
|
|
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: resurfacedIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Resurfaced issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByUserId: userId,
|
|
|
|
|
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
|
|
|
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-02 09:11:49 -05:00
|
|
|
await svc.archiveInbox(companyId, archivedIssueId, userId, new Date("2026-03-26T12:30:00.000Z"));
|
|
|
|
|
await svc.archiveInbox(companyId, resurfacedIssueId, userId, new Date("2026-03-26T13:00:00.000Z"));
|
2026-03-26 08:19:16 -05:00
|
|
|
|
|
|
|
|
await db.insert(issueComments).values({
|
|
|
|
|
companyId,
|
|
|
|
|
issueId: resurfacedIssueId,
|
|
|
|
|
authorUserId: otherUserId,
|
|
|
|
|
body: "This should bring the issue back into Mine.",
|
|
|
|
|
createdAt: new Date("2026-03-26T13:30:00.000Z"),
|
|
|
|
|
updatedAt: new Date("2026-03-26T13:30:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const archivedFiltered = await svc.list(companyId, {
|
|
|
|
|
touchedByUserId: userId,
|
|
|
|
|
inboxArchivedByUserId: userId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(archivedFiltered.map((issue) => issue.id)).toEqual([
|
|
|
|
|
resurfacedIssueId,
|
|
|
|
|
visibleIssueId,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await svc.unarchiveInbox(companyId, archivedIssueId, userId);
|
|
|
|
|
|
|
|
|
|
const afterUnarchive = await svc.list(companyId, {
|
|
|
|
|
touchedByUserId: userId,
|
|
|
|
|
inboxArchivedByUserId: userId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(new Set(afterUnarchive.map((issue) => issue.id))).toEqual(new Set([
|
|
|
|
|
visibleIssueId,
|
|
|
|
|
archivedIssueId,
|
|
|
|
|
resurfacedIssueId,
|
|
|
|
|
]));
|
|
|
|
|
});
|
2026-04-02 11:38:57 -05:00
|
|
|
|
|
|
|
|
it("resurfaces archived issue when status/updatedAt changes after archiving", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const userId = "user-1";
|
|
|
|
|
const otherUserId = "user-2";
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const issueId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: issueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Issue with old comment then status change",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
createdByUserId: userId,
|
|
|
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
|
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Old external comment before archiving
|
|
|
|
|
await db.insert(issueComments).values({
|
|
|
|
|
companyId,
|
|
|
|
|
issueId,
|
|
|
|
|
authorUserId: otherUserId,
|
|
|
|
|
body: "Old comment before archive",
|
|
|
|
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
|
|
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Archive after seeing the comment
|
|
|
|
|
await svc.archiveInbox(
|
|
|
|
|
companyId,
|
|
|
|
|
issueId,
|
|
|
|
|
userId,
|
|
|
|
|
new Date("2026-03-26T12:00:00.000Z"),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Verify it's archived
|
|
|
|
|
const afterArchive = await svc.list(companyId, {
|
|
|
|
|
touchedByUserId: userId,
|
|
|
|
|
inboxArchivedByUserId: userId,
|
|
|
|
|
});
|
|
|
|
|
expect(afterArchive.map((i) => i.id)).not.toContain(issueId);
|
|
|
|
|
|
|
|
|
|
// Status/work update changes updatedAt (no new comment)
|
|
|
|
|
await db
|
|
|
|
|
.update(issues)
|
|
|
|
|
.set({
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
|
|
|
})
|
|
|
|
|
.where(eq(issues.id, issueId));
|
|
|
|
|
|
|
|
|
|
// Should resurface because updatedAt > archivedAt
|
|
|
|
|
const afterUpdate = await svc.list(companyId, {
|
|
|
|
|
touchedByUserId: userId,
|
|
|
|
|
inboxArchivedByUserId: userId,
|
|
|
|
|
});
|
|
|
|
|
expect(afterUpdate.map((i) => i.id)).toContain(issueId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("sorts and exposes last activity from comments and non-local issue activity logs", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const olderIssueId = randomUUID();
|
|
|
|
|
const commentIssueId = randomUUID();
|
|
|
|
|
const activityIssueId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: olderIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Older issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: commentIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Comment activity issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: activityIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Logged activity issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(issueComments).values({
|
|
|
|
|
companyId,
|
|
|
|
|
issueId: commentIssueId,
|
|
|
|
|
body: "New comment without touching issue.updatedAt",
|
|
|
|
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
|
|
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(activityLog).values([
|
|
|
|
|
{
|
|
|
|
|
companyId,
|
|
|
|
|
actorType: "system",
|
|
|
|
|
actorId: "system",
|
|
|
|
|
action: "issue.document_updated",
|
|
|
|
|
entityType: "issue",
|
|
|
|
|
entityId: activityIssueId,
|
|
|
|
|
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
companyId,
|
|
|
|
|
actorType: "user",
|
|
|
|
|
actorId: "user-1",
|
|
|
|
|
action: "issue.read_marked",
|
|
|
|
|
entityType: "issue",
|
|
|
|
|
entityId: olderIssueId,
|
|
|
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const result = await svc.list(companyId, {});
|
|
|
|
|
|
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([
|
|
|
|
|
activityIssueId,
|
|
|
|
|
commentIssueId,
|
|
|
|
|
olderIssueId,
|
|
|
|
|
]);
|
|
|
|
|
expect(result.find((issue) => issue.id === activityIssueId)?.lastActivityAt?.toISOString()).toBe(
|
|
|
|
|
"2026-03-26T12:00:00.000Z",
|
|
|
|
|
);
|
|
|
|
|
expect(result.find((issue) => issue.id === commentIssueId)?.lastActivityAt?.toISOString()).toBe(
|
|
|
|
|
"2026-03-26T11:00:00.000Z",
|
|
|
|
|
);
|
|
|
|
|
expect(result.find((issue) => issue.id === olderIssueId)?.lastActivityAt?.toISOString()).toBe(
|
|
|
|
|
"2026-03-26T10:00:00.000Z",
|
|
|
|
|
);
|
|
|
|
|
});
|
Sync/master post pap1497 followups 2026 04 15 (#3779)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The board depends on issue, inbox, cost, and company-skill surfaces
to stay accurate and fast while agents are actively working
> - The PAP-1497 follow-up branch exposed a few rough edges in those
surfaces: stale active-run state on completed issues, missing creator
filters, oversized issue payload scans, and placeholder issue-route
parsing
> - Those gaps make the control plane harder to trust because operators
can see misleading run state, miss the right subset of work, or pay
extra query/render cost on large issue records
> - This pull request tightens those follow-ups across server and UI
code, and adds regression coverage for the affected paths
> - The benefit is a more reliable issue workflow, safer high-volume
cost aggregation, and clearer board/operator navigation
## What Changed
- Added the `v2026.415.0` release changelog entry.
- Fixed stale issue-run presentation after completion and reused the
shared issue-path parser so literal route placeholders no longer become
issue links.
- Added creator filters to the Issues page and Inbox, including
persisted filter-state normalization and regression coverage.
- Bounded issue detail/list project-mention scans and trimmed large
issue-list payload fields to keep issue reads lighter.
- Hardened company-skill list projection and cost/finance aggregation so
large markdown blobs and large summed values do not leak into list
responses or overflow 32-bit casts.
- Added targeted server/UI regression tests for company skills,
costs/finance, issue mention scanning, creator filters, inbox
normalization, and issue reference parsing.
## Verification
- `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/costs-service.test.ts
server/src/__tests__/issues-goal-context-routes.test.ts
server/src/__tests__/issues-service.test.ts ui/src/lib/inbox.test.ts
ui/src/lib/issue-filters.test.ts ui/src/lib/issue-reference.test.ts`
- `gh pr checks 3779`
Current pass set on the PR head: `policy`, `verify`, `e2e`,
`security/snyk (cryppadotta)`, `Greptile Review`
## Risks
- Creator filter options are derived from the currently loaded
issue/agent data, so very sparse result sets may not surface every
historical creator until they appear in the active dataset.
- Cost/finance aggregate casts now use `double precision`; that removes
the current overflow risk, but future schema changes should keep
large-value aggregation behavior under review.
- Issue detail mention scanning now skips comment-body scans on the
detail route, so any consumer that relied on comment-only project
mentions there would need to fetch them separately.
## Model Used
- OpenAI Codex, GPT-5-based coding agent with terminal tool use and
local code execution in the Paperclip workspace. Exact internal model
ID/context-window exposure is not surfaced in this session.
## 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 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
- [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>
2026-04-15 21:13:56 -05:00
|
|
|
|
2026-04-26 16:23:53 -07:00
|
|
|
it("paginates earlier comments in descending order from an anchor comment", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const issueId = randomUUID();
|
|
|
|
|
const firstCommentId = randomUUID();
|
|
|
|
|
const anchorCommentId = randomUUID();
|
|
|
|
|
const latestCommentId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: issueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Paged comments issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issueComments).values([
|
|
|
|
|
{
|
|
|
|
|
id: firstCommentId,
|
|
|
|
|
companyId,
|
|
|
|
|
issueId,
|
|
|
|
|
body: "First comment",
|
|
|
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
|
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: anchorCommentId,
|
|
|
|
|
companyId,
|
|
|
|
|
issueId,
|
|
|
|
|
body: "Anchor comment",
|
|
|
|
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
|
|
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: latestCommentId,
|
|
|
|
|
companyId,
|
|
|
|
|
issueId,
|
|
|
|
|
body: "Latest comment",
|
|
|
|
|
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
|
|
|
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const comments = await svc.listComments(issueId, {
|
|
|
|
|
afterCommentId: anchorCommentId,
|
|
|
|
|
order: "desc",
|
|
|
|
|
limit: 50,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(comments.map((comment) => comment.id)).toEqual([firstCommentId]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 12:56:51 -05:00
|
|
|
it("lists user comments when derived run attribution scans a timestamp window", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const agentId = randomUUID();
|
|
|
|
|
const issueId = randomUUID();
|
|
|
|
|
const commentId = 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: agentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "CodexCoder",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
permissions: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: issueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Comments issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
|
|
|
id: randomUUID(),
|
|
|
|
|
companyId,
|
|
|
|
|
agentId,
|
|
|
|
|
contextSnapshot: { issueId },
|
|
|
|
|
createdAt: new Date("2026-05-12T22:58:00.000Z"),
|
|
|
|
|
startedAt: new Date("2026-05-12T22:58:00.000Z"),
|
|
|
|
|
finishedAt: new Date("2026-05-12T23:14:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issueComments).values({
|
|
|
|
|
id: commentId,
|
|
|
|
|
companyId,
|
|
|
|
|
issueId,
|
|
|
|
|
authorUserId: "user-1",
|
|
|
|
|
body: "Comment should be visible",
|
|
|
|
|
createdAt: new Date("2026-05-12T23:00:00.000Z"),
|
|
|
|
|
updatedAt: new Date("2026-05-12T23:00:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const comments = await svc.listComments(issueId, {
|
|
|
|
|
order: "desc",
|
|
|
|
|
limit: 50,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(comments.map((comment) => comment.id)).toEqual([commentId]);
|
|
|
|
|
expect(comments[0]?.body).toBe("Comment should be visible");
|
|
|
|
|
});
|
|
|
|
|
|
Fix company export with missing run logs (#5960)
## Thinking Path
> - Paperclip is the control plane for autonomous AI companies.
> - Company export/import lets operators move company state, including
issue threads and agent execution context, between Paperclip instances.
> - Issue comments can be enriched by nearby heartbeat run logs so
exported threads preserve useful agent/run attribution metadata.
> - Some local instances can have heartbeat run database rows whose
local log files were deleted or never copied into the current workspace.
> - The export path should still include the original user comments
instead of failing because optional run-log metadata is unavailable.
> - This pull request makes comment run-log metadata derivation tolerate
missing local log files, logs the missing-file condition for operators,
and adds a regression test.
> - The benefit is safer company exports for real instances with
incomplete local run-log storage.
## What Changed
- Treat missing local heartbeat run logs as absent optional metadata
while listing issue comments.
- Emit a structured warning with `runId` and `logRef` when optional
comment-attribution log content is missing.
- Preserve the existing error behavior for non-404 run-log read
failures.
- Added a regression test proving user comments still list when a
candidate attribution run has a missing local log reference.
## Verification
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t
"candidate attribution run log is missing"` passed: 1 selected test
passed, 47 skipped.
- `pnpm --filter @paperclipai/server typecheck` passed.
- Greptile Review passed with Confidence Score 5/5 and zero unresolved
threads on commit `f68cac02bf98d7d31e7831e5bdfa95cffa85e254`.
- GitHub PR workflow run succeeded: `policy`, `verify`, four serialized
server suites, `e2e`, and `Canary Dry Run` all passed.
- `security/snyk (cryppadotta)` passed.
- Confirmed this branch is on top of `public-gh/master` and
`pnpm-lock.yaml` is not in the PR diff.
## Risks
- Low risk. The change only softens optional comment metadata derivation
for 404/missing local log files; other log read errors still throw.
- Exported comments in this edge case may lack derived run metadata, but
they remain visible/exportable instead of failing the request.
- Operators may see new warnings when historical run-log references
point to missing local files; those warnings indicate degraded optional
metadata, not data loss.
## Model Used
- OpenAI Codex, GPT-5 coding agent in this Paperclip heartbeat, with
shell/git/GitHub CLI tool use.
## 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>
2026-05-14 08:37:04 -05:00
|
|
|
it("lists user comments when a candidate attribution run log is missing", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const agentId = randomUUID();
|
|
|
|
|
const issueId = randomUUID();
|
|
|
|
|
const commentId = 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: agentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "CodexCoder",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
permissions: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: issueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Comments issue with missing run log",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
|
|
|
id: randomUUID(),
|
|
|
|
|
companyId,
|
|
|
|
|
agentId,
|
|
|
|
|
contextSnapshot: { issueId },
|
|
|
|
|
createdAt: new Date("2026-05-12T22:58:00.000Z"),
|
|
|
|
|
startedAt: new Date("2026-05-12T22:58:00.000Z"),
|
|
|
|
|
finishedAt: new Date("2026-05-12T23:14:00.000Z"),
|
|
|
|
|
logStore: "local_file",
|
|
|
|
|
logRef: "missing/run-log.ndjson",
|
|
|
|
|
logBytes: 128,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issueComments).values({
|
|
|
|
|
id: commentId,
|
|
|
|
|
companyId,
|
|
|
|
|
issueId,
|
|
|
|
|
authorUserId: "user-1",
|
|
|
|
|
body: "Comment should still be visible",
|
|
|
|
|
createdAt: new Date("2026-05-12T23:00:00.000Z"),
|
|
|
|
|
updatedAt: new Date("2026-05-12T23:00:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const comments = await svc.listComments(issueId, {
|
|
|
|
|
order: "desc",
|
|
|
|
|
limit: 50,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(comments.map((comment) => comment.id)).toEqual([commentId]);
|
|
|
|
|
expect(comments[0]?.body).toBe("Comment should still be visible");
|
|
|
|
|
expect(comments[0]?.metadata).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
Present ordered sub-issues as a workflow checklist (#4523)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - Operators use issue detail pages and child issue lists to understand
multi-step execution plans.
> - Ordered sub-issues currently read like a flat table, so dependency
chains and current next steps are harder to scan.
> - The branch work adds a workflow-oriented presentation for child
issues without changing the single-assignee task model.
> - This pull request makes ordered sub-issues read more like a progress
checklist while preserving normal issue list controls.
> - The benefit is that operators can see completed steps, active work,
blocked follow-ups, and dependency order at a glance.
## What Changed
- Added workflow sorting utilities and tests for dependency-aware child
issue ordering.
- Added sub-issue progress summary, checklist numbering, current-step
affordances, blocker context, and done-state de-emphasis in the issue
list UI.
- Wired issue detail sub-issue panels to use the workflow sort/progress
checklist presentation.
- Updated issue service behavior/tests for child issue ordering inputs
used by the UI.
- Added a Storybook visual review fixture and screenshot helper for the
sub-issue workflow checklist surface.
## Verification
- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/issues-service.test.ts
ui/src/components/IssueRow.test.tsx
ui/src/components/IssuesList.test.tsx ui/src/pages/IssueDetail.test.tsx
ui/src/lib/issue-detail-subissues.test.ts
ui/src/lib/workflow-sort.test.ts`
- Result: 6 test files passed, 55 tests passed, 34 embedded Postgres
issue-service tests skipped because `@embedded-postgres/darwin-x64` is
unavailable on this host.
- Visual review: generated Storybook screenshots from the existing local
Storybook server on port 6006 with `node
scripts/screenshot-subissues.mjs /tmp/pap-2189-subissues-screens
http://localhost:6006`.
- Screenshot artifacts:
- Desktop dark: 
- Desktop light: 
- Mobile dark: 
- Mobile light: 
- Local Storybook note: starting a second Storybook process selected
port 6008 because 6006 was occupied, then Vite failed with an esbuild
host/binary version mismatch (`0.25.12` host vs `0.27.3` binary). The
already-running Storybook server on 6006 served the fixture successfully
for screenshots.
## Risks
- Medium UI risk: the issue list now has additional sub-issue-specific
visual states, so dense lists should be checked for spacing and
scanability.
- Low ordering risk: workflow sorting is covered by focused unit tests,
but unusual dependency topologies may still need reviewer attention.
- No migration risk: this PR does not add database migrations or touch
`pnpm-lock.yaml`.
> 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, GPT-5 coding agent, tool-enabled shell/git/GitHub
workflow. Context window is runtime-provided and not exposed in this
environment.
## 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>
2026-04-26 07:36:49 -05:00
|
|
|
it("includes blockedBy summaries on list rows in one batched pass", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const blockerId = randomUUID();
|
|
|
|
|
const blockedId = randomUUID();
|
|
|
|
|
const unblockedId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: blockerId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Blocker issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "high",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: blockedId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Blocked issue",
|
|
|
|
|
status: "blocked",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: unblockedId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Unblocked issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(issueRelations).values({
|
|
|
|
|
companyId,
|
|
|
|
|
issueId: blockerId,
|
|
|
|
|
relatedIssueId: blockedId,
|
|
|
|
|
type: "blocks",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const defaultResult = await svc.list(companyId);
|
|
|
|
|
expect(defaultResult.find((issue) => issue.id === blockedId)?.blockedBy).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
const result = await svc.list(companyId, { includeBlockedBy: true });
|
|
|
|
|
const byId = new Map(result.map((issue) => [issue.id, issue]));
|
|
|
|
|
|
|
|
|
|
expect(byId.get(blockedId)?.blockedBy).toEqual([
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
id: blockerId,
|
|
|
|
|
identifier: null,
|
|
|
|
|
title: "Blocker issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "high",
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
expect(byId.get(blockerId)?.blockedBy).toEqual([]);
|
|
|
|
|
expect(byId.get(unblockedId)?.blockedBy).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
Sync/master post pap1497 followups 2026 04 15 (#3779)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The board depends on issue, inbox, cost, and company-skill surfaces
to stay accurate and fast while agents are actively working
> - The PAP-1497 follow-up branch exposed a few rough edges in those
surfaces: stale active-run state on completed issues, missing creator
filters, oversized issue payload scans, and placeholder issue-route
parsing
> - Those gaps make the control plane harder to trust because operators
can see misleading run state, miss the right subset of work, or pay
extra query/render cost on large issue records
> - This pull request tightens those follow-ups across server and UI
code, and adds regression coverage for the affected paths
> - The benefit is a more reliable issue workflow, safer high-volume
cost aggregation, and clearer board/operator navigation
## What Changed
- Added the `v2026.415.0` release changelog entry.
- Fixed stale issue-run presentation after completion and reused the
shared issue-path parser so literal route placeholders no longer become
issue links.
- Added creator filters to the Issues page and Inbox, including
persisted filter-state normalization and regression coverage.
- Bounded issue detail/list project-mention scans and trimmed large
issue-list payload fields to keep issue reads lighter.
- Hardened company-skill list projection and cost/finance aggregation so
large markdown blobs and large summed values do not leak into list
responses or overflow 32-bit casts.
- Added targeted server/UI regression tests for company skills,
costs/finance, issue mention scanning, creator filters, inbox
normalization, and issue reference parsing.
## Verification
- `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/costs-service.test.ts
server/src/__tests__/issues-goal-context-routes.test.ts
server/src/__tests__/issues-service.test.ts ui/src/lib/inbox.test.ts
ui/src/lib/issue-filters.test.ts ui/src/lib/issue-reference.test.ts`
- `gh pr checks 3779`
Current pass set on the PR head: `policy`, `verify`, `e2e`,
`security/snyk (cryppadotta)`, `Greptile Review`
## Risks
- Creator filter options are derived from the currently loaded
issue/agent data, so very sparse result sets may not surface every
historical creator until they appear in the active dataset.
- Cost/finance aggregate casts now use `double precision`; that removes
the current overflow risk, but future schema changes should keep
large-value aggregation behavior under review.
- Issue detail mention scanning now skips comment-body scans on the
detail route, so any consumer that relied on comment-only project
mentions there would need to fetch them separately.
## Model Used
- OpenAI Codex, GPT-5-based coding agent with terminal tool use and
local code execution in the Paperclip workspace. Exact internal model
ID/context-window exposure is not surfaced in this session.
## 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 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
- [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>
2026-04-15 21:13:56 -05:00
|
|
|
it("trims list payload fields that can grow large on issue index routes", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const issueId = randomUUID();
|
|
|
|
|
const longDescription = "x".repeat(5_000);
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: issueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Large issue",
|
|
|
|
|
description: longDescription,
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
executionPolicy: { stages: Array.from({ length: 20 }, (_, index) => ({ index, kind: "review", notes: "y".repeat(400) })) },
|
|
|
|
|
executionState: { history: Array.from({ length: 20 }, (_, index) => ({ index, body: "z".repeat(400) })) },
|
|
|
|
|
executionWorkspaceSettings: { notes: "w".repeat(2_000) },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const [result] = await svc.list(companyId);
|
|
|
|
|
|
|
|
|
|
expect(result).toBeTruthy();
|
|
|
|
|
expect(result?.description).toHaveLength(1200);
|
|
|
|
|
expect(result?.executionPolicy).toBeNull();
|
|
|
|
|
expect(result?.executionState).toBeNull();
|
|
|
|
|
expect(result?.executionWorkspaceSettings).toBeNull();
|
|
|
|
|
});
|
[codex] Harden heartbeat scheduling and runtime controls (#4223)
## Thinking Path
> - Paperclip orchestrates AI agents through issue checkout, heartbeat
runs, routines, and auditable control-plane state
> - The runtime path has to recover from lost local processes, transient
adapter failures, blocked dependencies, and routine coalescing without
stranding work
> - The existing branch carried several reliability fixes across
heartbeat scheduling, issue runtime controls, routine dispatch, and
operator-facing run state
> - These changes belong together because they share backend contracts,
migrations, and runtime status semantics
> - This pull request groups the control-plane/runtime slice so it can
merge independently from board UI polish and adapter sandbox work
> - The benefit is safer heartbeat recovery, clearer runtime controls,
and more predictable recurring execution behavior
## What Changed
- Adds bounded heartbeat retry scheduling, scheduled retry state, and
Codex transient failure recovery handling.
- Tightens heartbeat process recovery, blocker wake behavior, issue
comment wake handling, routine dispatch coalescing, and
activity/dashboard bounds.
- Adds runtime-control MCP tools and Paperclip skill docs for issue
workspace runtime management.
- Adds migrations `0061_lively_thor_girl.sql` and
`0062_routine_run_dispatch_fingerprint.sql`.
- Surfaces retry state in run ledger/agent UI and keeps related shared
types synchronized.
## Verification
- `pnpm exec vitest run
server/src/__tests__/heartbeat-retry-scheduling.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts
server/src/__tests__/routines-service.test.ts`
- `pnpm exec vitest run src/tools.test.ts` from `packages/mcp-server`
## Risks
- Medium risk: this touches heartbeat recovery and routine dispatch,
which are central execution paths.
- Migration order matters if split branches land out of order: merge
this PR before branches that assume the new runtime/routine fields.
- Runtime retry behavior should be watched in CI and in local operator
smoke tests because it changes how transient failures are resumed.
> 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, GPT-5-based coding agent runtime, shell/git tool use
enabled. Exact hosted model build and context window are not exposed in
this Paperclip heartbeat environment.
## 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
- [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
2026-04-21 12:24:11 -05:00
|
|
|
|
|
|
|
|
it("does not let description preview truncation split multibyte characters", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const issueId = randomUUID();
|
|
|
|
|
const description = `${"x".repeat(1199)}— still valid after truncation`;
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: issueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Multibyte boundary issue",
|
|
|
|
|
description,
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const [result] = await svc.list(companyId);
|
|
|
|
|
|
|
|
|
|
expect(result?.description).toHaveLength(1200);
|
|
|
|
|
expect(result?.description?.endsWith("—")).toBe(true);
|
|
|
|
|
});
|
2026-03-21 12:20:48 -05:00
|
|
|
});
|
2026-03-30 14:08:44 -05:00
|
|
|
|
|
|
|
|
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
|
|
|
|
let db!: ReturnType<typeof createDb>;
|
|
|
|
|
let svc!: ReturnType<typeof issueService>;
|
|
|
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
|
|
|
|
|
db = createDb(tempDb.connectionString);
|
|
|
|
|
svc = issueService(db);
|
2026-04-04 13:56:04 -05:00
|
|
|
await ensureIssueRelationsTable(db);
|
2026-03-30 14:08:44 -05:00
|
|
|
}, 20_000);
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await db.delete(issueComments);
|
2026-04-04 13:56:04 -05:00
|
|
|
await db.delete(issueRelations);
|
2026-03-30 14:08:44 -05:00
|
|
|
await db.delete(issueInboxArchives);
|
|
|
|
|
await db.delete(activityLog);
|
|
|
|
|
await db.delete(issues);
|
|
|
|
|
await db.delete(executionWorkspaces);
|
|
|
|
|
await db.delete(projectWorkspaces);
|
|
|
|
|
await db.delete(projects);
|
[codex] Add run liveness continuations (#4083)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - Heartbeat runs are the control-plane record of each agent execution
window.
> - Long-running local agents can exhaust context or stop while still
holding useful next-step state.
> - Operators need that stop reason, next action, and continuation path
to be durable and visible.
> - This pull request adds run liveness metadata, continuation
summaries, and UI surfaces for issue run ledgers.
> - The benefit is that interrupted or long-running work can resume with
clearer context instead of losing the agent's last useful handoff.
## What Changed
- Added heartbeat-run liveness fields, continuation attempt tracking,
and an idempotent `0058` migration.
- Added server services and tests for run liveness, continuation
summaries, stop metadata, and activity backfill.
- Wired local and HTTP adapters to surface continuation/liveness context
through shared adapter utilities.
- Added shared constants, validators, and heartbeat types for liveness
continuation state.
- Added issue-detail UI surfaces for continuation handoffs and the run
ledger, with component tests.
- Updated agent runtime docs, heartbeat protocol docs, prompt guidance,
onboarding assets, and skills instructions to explain continuation
behavior.
- Addressed Greptile feedback by scoping document evidence by run,
excluding system continuation-summary documents from liveness evidence,
importing shared liveness types, surfacing hidden ledger run counts,
documenting bounded retry behavior, and moving run-ledger liveness
backfill off the request path.
## Verification
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/run-continuations.test.ts
server/src/__tests__/run-liveness.test.ts
server/src/__tests__/activity-service.test.ts
server/src/__tests__/documents-service.test.ts
server/src/__tests__/issue-continuation-summary.test.ts
server/src/services/heartbeat-stop-metadata.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/components/IssueContinuationHandoff.test.tsx
ui/src/components/IssueDocumentsSection.test.tsx`
- `pnpm --filter @paperclipai/db build`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/run-continuations.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "treats a
plan document update"`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts -t "activity
service|treats a plan document update"`
- Remote PR checks on head `e53b1a1d`: `verify`, `e2e`, `policy`, and
Snyk all passed.
- Confirmed `public-gh/master` is an ancestor of this branch after
fetching `public-gh master`.
- Confirmed `pnpm-lock.yaml` is not included in the branch diff.
- Confirmed migration `0058_wealthy_starbolt.sql` is ordered after
`0057` and uses `IF NOT EXISTS` guards for repeat application.
- Greptile inline review threads are resolved.
## Risks
- Medium risk: this touches heartbeat execution, liveness recovery,
activity rendering, issue routes, shared contracts, docs, and UI.
- Migration risk is mitigated by additive columns/indexes and idempotent
guards.
- Run-ledger liveness backfill is now asynchronous, so the first ledger
response can briefly show historical missing liveness until the
background backfill completes.
- UI screenshot coverage is not included in this packaging pass;
validation is currently through focused component tests.
> 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, GPT-5.4, local tool-use coding agent with terminal, git,
GitHub connector, GitHub CLI, and Paperclip API access.
## 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
Screenshot note: no before/after screenshots were captured in this PR
packaging pass; the UI changes are covered by focused component tests
listed above.
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 06:01:49 -05:00
|
|
|
await db.delete(goals);
|
2026-03-30 14:08:44 -05:00
|
|
|
await db.delete(agents);
|
|
|
|
|
await db.delete(instanceSettings);
|
|
|
|
|
await db.delete(companies);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await tempDb?.cleanup();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const parentIssueId = 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 instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Primary workspace",
|
|
|
|
|
isPrimary: true,
|
|
|
|
|
sharedWorkspaceKey: "workspace-key",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
|
|
|
id: executionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
strategyType: "git_worktree",
|
|
|
|
|
name: "Issue worktree",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "git_worktree",
|
|
|
|
|
providerRef: `/tmp/${executionWorkspaceId}`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: parentIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
title: "Parent issue",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
executionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: {
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
workspaceRuntime: { profile: "agent" },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const child = await svc.create(companyId, {
|
|
|
|
|
parentId: parentIssueId,
|
|
|
|
|
projectId,
|
|
|
|
|
title: "Child issue",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
|
|
|
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
|
|
|
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
|
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
|
|
|
expect(child.executionWorkspaceSettings).toEqual({
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
workspaceRuntime: { profile: "agent" },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
Honor reuse-existing preference and assignee default environment in issue runs (#5139)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Agents run inside execution workspaces (a per-issue cwd + env), and
an issue
> can prefer to reuse an existing workspace or get a fresh one each time
> - The heartbeat service was reading the existing workspace's config to
derive
> environment selection regardless of whether the issue actually wanted
to reuse
> it. So fresh-run issues were inheriting stale config from a workspace
that was
> about to be discarded
> - Separately, when an issue is assigned to an agent, the issue's
execution
> workspace settings weren't picking up the agent's
`defaultEnvironmentId`,
> even though the agent's choice is the natural default for that issue
> - This PR makes both selection paths honor the obvious source of
truth:
> workspace config flows only when the issue actually wants
`reuse_existing`,
> and the assignee agent's default environment is applied at assignment
time if
> nothing else is set on the issue
> - The benefit is that re-running a flaky issue picks up the right
environment
> instead of inheriting the previous run's config, and assigning an
agent to an
> issue does the obvious thing without operator intervention
## What Changed
- `server/src/services/heartbeat.ts`: introduce
`reusableExecutionWorkspaceConfig`
that is non-null only when `shouldReuseExisting` is true. Both
`resolveExecutionWorkspaceEnvironmentId(...)` and
`applyPersistedExecutionWorkspaceConfig(...)` now read from it instead
of
unconditionally consulting `existingExecutionWorkspace?.config`.
Fresh-run
issues no longer inherit stale environment config from an in-flight
workspace
about to be discarded.
- `server/src/services/issues.ts`: when an issue update sets a new
`assigneeAgentId` and isolated workspaces are enabled, populate
`executionWorkspaceSettings.environmentId` from the assignee agent's
`defaultEnvironmentId` if the issue doesn't have an explicit
`environmentId` set yet.
- Tests added in `heartbeat-plugin-environment.test.ts` (~216 lines) and
`issues-service.test.ts` (~85 lines) covering both paths.
## Verification
- `pnpm --filter @paperclipai/server test --
heartbeat-plugin-environment issues-service`
- Manual QA: assign an issue to an agent that has a non-default
`defaultEnvironmentId`, confirm the issue's workspace settings now
include that
environment id without operator intervention. Trigger a rerun on an
issue
whose existing workspace points at a stale environment, confirm the
rerun uses
the freshly-resolved environment.
## Risks
- Behavioural shift on assignment: previously assigning an agent didn't
propagate the agent's default environment to the issue. Now it does.
Callers
that explicitly want the issue to keep its existing/null environment
must set
`executionWorkspaceSettings.environmentId` themselves; the new logic
only
fires when no explicit value is set.
- Behavioural shift on rerun: stale workspace config is no longer
applied to
fresh runs. Operators who relied on this implicit inheritance may see
different environment selection on the first rerun after deploy.
Mitigation:
the explicit isssue settings and project policy are still honored as
before.
## Model Used
- OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI
- Provider: OpenAI
- Used to author the code changes in this PR
## 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 (no UI changes)
- [ ] I have updated relevant documentation to reflect my changes — N/A
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-05-03 18:33:55 -07:00
|
|
|
it("captures the assignee default environment when neither issue nor project specifies one", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const projectWorkspaceId = randomUUID();
|
|
|
|
|
const assigneeEnvironmentId = 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 instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
|
|
|
|
|
|
await db.insert(environments).values([
|
|
|
|
|
{
|
|
|
|
|
id: assigneeEnvironmentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "QA E2B",
|
|
|
|
|
driver: "sandbox",
|
|
|
|
|
status: "active",
|
|
|
|
|
config: { provider: "e2b" },
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(agents).values({
|
|
|
|
|
id: assigneeAgentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "QA E2B Codex",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
defaultEnvironmentId: assigneeEnvironmentId,
|
|
|
|
|
permissions: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
executionWorkspacePolicy: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
defaultMode: "shared_workspace",
|
|
|
|
|
allowIssueOverride: true,
|
|
|
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Primary workspace",
|
|
|
|
|
isPrimary: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const issue = await svc.create(companyId, {
|
|
|
|
|
projectId,
|
|
|
|
|
assigneeAgentId,
|
|
|
|
|
title: "Environment matrix: e2b / codex_local",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(issue.executionWorkspaceSettings).toEqual({
|
|
|
|
|
mode: "shared_workspace",
|
|
|
|
|
environmentId: assigneeEnvironmentId,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not promote the assignee default environment when the project policy already specifies one", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const projectWorkspaceId = randomUUID();
|
|
|
|
|
const projectEnvironmentId = randomUUID();
|
|
|
|
|
const assigneeEnvironmentId = 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 instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
|
|
|
|
|
|
await db.insert(environments).values([
|
|
|
|
|
{
|
|
|
|
|
id: projectEnvironmentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "QA SSH",
|
|
|
|
|
driver: "ssh",
|
|
|
|
|
status: "active",
|
|
|
|
|
config: {},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: assigneeEnvironmentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "QA E2B",
|
|
|
|
|
driver: "sandbox",
|
|
|
|
|
status: "active",
|
|
|
|
|
config: { provider: "e2b" },
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(agents).values({
|
|
|
|
|
id: assigneeAgentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "QA E2B Codex",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
defaultEnvironmentId: assigneeEnvironmentId,
|
|
|
|
|
permissions: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
executionWorkspacePolicy: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
defaultMode: "shared_workspace",
|
|
|
|
|
allowIssueOverride: true,
|
|
|
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
|
|
|
environmentId: projectEnvironmentId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Primary workspace",
|
|
|
|
|
isPrimary: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const issue = await svc.create(companyId, {
|
|
|
|
|
projectId,
|
|
|
|
|
assigneeAgentId,
|
|
|
|
|
title: "Environment matrix: e2b / codex_local",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Project policy's environmentId must win over the assignee's default;
|
|
|
|
|
// executionWorkspaceSettings should not bake in an environmentId in this case
|
|
|
|
|
// so resolveExecutionWorkspaceEnvironmentId can fall through to the project
|
|
|
|
|
// policy's value at run time.
|
|
|
|
|
expect(issue.executionWorkspaceSettings).toEqual({ mode: "shared_workspace" });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("captures the new assignee's default environment on reassignment", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const projectWorkspaceId = randomUUID();
|
|
|
|
|
const firstEnvironmentId = randomUUID();
|
|
|
|
|
const secondEnvironmentId = randomUUID();
|
|
|
|
|
const firstAgentId = randomUUID();
|
|
|
|
|
const secondAgentId = 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: true });
|
|
|
|
|
|
|
|
|
|
await db.insert(environments).values([
|
|
|
|
|
{
|
|
|
|
|
id: firstEnvironmentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "QA SSH",
|
|
|
|
|
driver: "ssh",
|
|
|
|
|
status: "active",
|
|
|
|
|
config: {},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: secondEnvironmentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "QA E2B",
|
|
|
|
|
driver: "sandbox",
|
|
|
|
|
status: "active",
|
|
|
|
|
config: { provider: "e2b" },
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(agents).values([
|
|
|
|
|
{
|
|
|
|
|
id: firstAgentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "QA SSH Codex",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
defaultEnvironmentId: firstEnvironmentId,
|
|
|
|
|
permissions: {},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: secondAgentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "QA E2B Codex",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
defaultEnvironmentId: secondEnvironmentId,
|
|
|
|
|
permissions: {},
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
executionWorkspacePolicy: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
defaultMode: "shared_workspace",
|
|
|
|
|
allowIssueOverride: true,
|
|
|
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Primary workspace",
|
|
|
|
|
isPrimary: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const created = await svc.create(companyId, {
|
|
|
|
|
projectId,
|
|
|
|
|
assigneeAgentId: firstAgentId,
|
|
|
|
|
title: "Environment matrix: ssh / codex_local",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(created.executionWorkspaceSettings).toMatchObject({
|
|
|
|
|
environmentId: firstEnvironmentId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const reassigned = await svc.update(created.id, {
|
|
|
|
|
assigneeAgentId: secondAgentId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(reassigned).not.toBeNull();
|
|
|
|
|
expect(reassigned!.executionWorkspaceSettings).toMatchObject({
|
|
|
|
|
environmentId: secondEnvironmentId,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("preserves an operator-set environmentId across reassignment", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const projectWorkspaceId = randomUUID();
|
|
|
|
|
const firstEnvironmentId = randomUUID();
|
|
|
|
|
const secondEnvironmentId = randomUUID();
|
|
|
|
|
const operatorEnvironmentId = randomUUID();
|
|
|
|
|
const firstAgentId = randomUUID();
|
|
|
|
|
const secondAgentId = 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: true });
|
|
|
|
|
|
|
|
|
|
await db.insert(environments).values([
|
|
|
|
|
{ id: firstEnvironmentId, companyId, name: "Env 1", driver: "ssh", status: "active", config: {} },
|
|
|
|
|
{ id: secondEnvironmentId, companyId, name: "Env 2", driver: "sandbox", status: "active", config: { provider: "e2b" } },
|
|
|
|
|
{ id: operatorEnvironmentId, companyId, name: "Operator pick", driver: "ssh", status: "active", config: {} },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(agents).values([
|
|
|
|
|
{
|
|
|
|
|
id: firstAgentId, companyId, name: "First agent", role: "engineer", status: "active",
|
|
|
|
|
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
|
|
|
|
|
defaultEnvironmentId: firstEnvironmentId, permissions: {},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: secondAgentId, companyId, name: "Second agent", role: "engineer", status: "active",
|
|
|
|
|
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
|
|
|
|
|
defaultEnvironmentId: secondEnvironmentId, permissions: {},
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId, companyId, name: "Workspace project", status: "in_progress",
|
|
|
|
|
executionWorkspacePolicy: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
defaultMode: "shared_workspace",
|
|
|
|
|
allowIssueOverride: true,
|
|
|
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId, companyId, projectId, name: "Primary workspace", isPrimary: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const created = await svc.create(companyId, {
|
|
|
|
|
projectId,
|
|
|
|
|
assigneeAgentId: firstAgentId,
|
|
|
|
|
title: "Operator overrides env then reassigns",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Operator explicitly overrides the environmentId in a separate update.
|
|
|
|
|
const overridden = await svc.update(created.id, {
|
|
|
|
|
executionWorkspaceSettings: {
|
|
|
|
|
mode: "shared_workspace",
|
|
|
|
|
environmentId: operatorEnvironmentId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
expect(overridden!.executionWorkspaceSettings).toMatchObject({
|
|
|
|
|
environmentId: operatorEnvironmentId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// A subsequent reassignment-only update must NOT overwrite the operator's
|
|
|
|
|
// explicit choice with the new assignee's default.
|
|
|
|
|
const reassigned = await svc.update(created.id, {
|
|
|
|
|
assigneeAgentId: secondAgentId,
|
|
|
|
|
});
|
|
|
|
|
expect(reassigned!.executionWorkspaceSettings).toMatchObject({
|
|
|
|
|
environmentId: operatorEnvironmentId,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-30 14:08:44 -05:00
|
|
|
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const parentIssueId = randomUUID();
|
|
|
|
|
const parentProjectWorkspaceId = randomUUID();
|
|
|
|
|
const parentExecutionWorkspaceId = randomUUID();
|
|
|
|
|
const explicitProjectWorkspaceId = randomUUID();
|
|
|
|
|
const explicitExecutionWorkspaceId = 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: true });
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values([
|
|
|
|
|
{
|
|
|
|
|
id: parentProjectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Parent workspace",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: explicitProjectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Explicit workspace",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(executionWorkspaces).values([
|
|
|
|
|
{
|
|
|
|
|
id: parentExecutionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
strategyType: "git_worktree",
|
|
|
|
|
name: "Parent worktree",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "git_worktree",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: explicitExecutionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId: explicitProjectWorkspaceId,
|
|
|
|
|
mode: "shared_workspace",
|
|
|
|
|
strategyType: "project_primary",
|
|
|
|
|
name: "Explicit shared workspace",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "local_fs",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: parentIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
|
|
|
title: "Parent issue",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
executionWorkspaceId: parentExecutionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: {
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const child = await svc.create(companyId, {
|
|
|
|
|
parentId: parentIssueId,
|
|
|
|
|
projectId,
|
|
|
|
|
title: "Child issue",
|
|
|
|
|
projectWorkspaceId: explicitProjectWorkspaceId,
|
|
|
|
|
executionWorkspaceId: explicitExecutionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: {
|
|
|
|
|
mode: "shared_workspace",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId);
|
|
|
|
|
expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId);
|
|
|
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
|
|
|
expect(child.executionWorkspaceSettings).toEqual({
|
|
|
|
|
mode: "shared_workspace",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const sourceIssueId = 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 instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Primary workspace",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
|
|
|
id: executionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
mode: "operator_branch",
|
|
|
|
|
strategyType: "git_worktree",
|
|
|
|
|
name: "Operator branch",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "git_worktree",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: sourceIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
title: "Source issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
executionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: {
|
|
|
|
|
mode: "operator_branch",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const followUp = await svc.create(companyId, {
|
|
|
|
|
projectId,
|
|
|
|
|
title: "Follow-up issue",
|
|
|
|
|
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(followUp.parentId).toBeNull();
|
|
|
|
|
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
|
|
|
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
|
|
|
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
|
|
|
|
|
expect(followUp.executionWorkspaceSettings).toEqual({
|
|
|
|
|
mode: "operator_branch",
|
|
|
|
|
});
|
|
|
|
|
});
|
[codex] Add run liveness continuations (#4083)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - Heartbeat runs are the control-plane record of each agent execution
window.
> - Long-running local agents can exhaust context or stop while still
holding useful next-step state.
> - Operators need that stop reason, next action, and continuation path
to be durable and visible.
> - This pull request adds run liveness metadata, continuation
summaries, and UI surfaces for issue run ledgers.
> - The benefit is that interrupted or long-running work can resume with
clearer context instead of losing the agent's last useful handoff.
## What Changed
- Added heartbeat-run liveness fields, continuation attempt tracking,
and an idempotent `0058` migration.
- Added server services and tests for run liveness, continuation
summaries, stop metadata, and activity backfill.
- Wired local and HTTP adapters to surface continuation/liveness context
through shared adapter utilities.
- Added shared constants, validators, and heartbeat types for liveness
continuation state.
- Added issue-detail UI surfaces for continuation handoffs and the run
ledger, with component tests.
- Updated agent runtime docs, heartbeat protocol docs, prompt guidance,
onboarding assets, and skills instructions to explain continuation
behavior.
- Addressed Greptile feedback by scoping document evidence by run,
excluding system continuation-summary documents from liveness evidence,
importing shared liveness types, surfacing hidden ledger run counts,
documenting bounded retry behavior, and moving run-ledger liveness
backfill off the request path.
## Verification
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/run-continuations.test.ts
server/src/__tests__/run-liveness.test.ts
server/src/__tests__/activity-service.test.ts
server/src/__tests__/documents-service.test.ts
server/src/__tests__/issue-continuation-summary.test.ts
server/src/services/heartbeat-stop-metadata.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/components/IssueContinuationHandoff.test.tsx
ui/src/components/IssueDocumentsSection.test.tsx`
- `pnpm --filter @paperclipai/db build`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/run-continuations.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "treats a
plan document update"`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts -t "activity
service|treats a plan document update"`
- Remote PR checks on head `e53b1a1d`: `verify`, `e2e`, `policy`, and
Snyk all passed.
- Confirmed `public-gh/master` is an ancestor of this branch after
fetching `public-gh master`.
- Confirmed `pnpm-lock.yaml` is not included in the branch diff.
- Confirmed migration `0058_wealthy_starbolt.sql` is ordered after
`0057` and uses `IF NOT EXISTS` guards for repeat application.
- Greptile inline review threads are resolved.
## Risks
- Medium risk: this touches heartbeat execution, liveness recovery,
activity rendering, issue routes, shared contracts, docs, and UI.
- Migration risk is mitigated by additive columns/indexes and idempotent
guards.
- Run-ledger liveness backfill is now asynchronous, so the first ledger
response can briefly show historical missing liveness until the
background backfill completes.
- UI screenshot coverage is not included in this packaging pass;
validation is currently through focused component tests.
> 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, GPT-5.4, local tool-use coding agent with terminal, git,
GitHub connector, GitHub CLI, and Paperclip API access.
## 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
Screenshot note: no before/after screenshots were captured in this PR
packaging pass; the UI changes are covered by focused component tests
listed above.
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 06:01:49 -05:00
|
|
|
|
|
|
|
|
it("createChild applies parent defaults, acceptance criteria, workspace inheritance, and optional parent blocker chaining", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const goalId = randomUUID();
|
|
|
|
|
const parentIssueId = 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 instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
|
|
|
|
|
|
await db.insert(goals).values({
|
|
|
|
|
id: goalId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Ship child helpers",
|
|
|
|
|
level: "task",
|
|
|
|
|
status: "active",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
goalId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Primary workspace",
|
|
|
|
|
isPrimary: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
|
|
|
id: executionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
strategyType: "git_worktree",
|
|
|
|
|
name: "Issue worktree",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "git_worktree",
|
|
|
|
|
providerRef: `/tmp/${executionWorkspaceId}`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: parentIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
goalId,
|
|
|
|
|
title: "Parent issue",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
requestDepth: 1,
|
|
|
|
|
executionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: {
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { issue: child, parentBlockerAdded } = await svc.createChild(parentIssueId, {
|
|
|
|
|
title: "Child helper",
|
|
|
|
|
status: "todo",
|
|
|
|
|
description: "Implement the helper.",
|
|
|
|
|
acceptanceCriteria: ["Uses the parent issue as parentId", "Reuses the parent execution workspace"],
|
|
|
|
|
blockParentUntilDone: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(parentBlockerAdded).toBe(true);
|
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
|
|
|
expect(child.projectId).toBe(projectId);
|
|
|
|
|
expect(child.goalId).toBe(goalId);
|
|
|
|
|
expect(child.requestDepth).toBe(2);
|
|
|
|
|
expect(child.description).toContain("## Acceptance Criteria");
|
|
|
|
|
expect(child.description).toContain("- Uses the parent issue as parentId");
|
|
|
|
|
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
|
|
|
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
|
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
|
|
|
|
|
|
|
|
const parentRelations = await svc.getRelationSummaries(parentIssueId);
|
|
|
|
|
expect(parentRelations.blockedBy).toEqual([
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
id: child.id,
|
|
|
|
|
title: "Child helper",
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
});
|
[codex] Split backend control-plane QoL slice (#4700)
## Thinking Path
> - Paperclip is the control plane for autonomous AI companies, so
backend task ownership, recovery, review visibility, and company-scoped
limits need to stay enforceable without UI-only coupling.
> - Closed PR #4692 bundled those backend changes with UI workflow,
docs, skills, workflow, and lockfile churn.
> - PAP-2694 asks for a clean backend/control-plane slice from that
closed branch.
> - This branch starts from current `master` and mines only the `cli`,
`packages/db`, `packages/shared`, and `server` contracts/tests needed
for the backend behavior.
> - It explicitly excludes UI workflow/performance work,
`.github/workflows/pr.yml`, `pnpm-lock.yaml`, docs, skills,
package-script, adapter UI build-config, and perf fixture script
changes; the only UI files are fixture/test updates required by the
tightened shared `Company` contract.
> - The benefit is a smaller reviewable PR that preserves the
control-plane fixes while staying under Greptile s 100-file review
limit.
## What Changed
- Added company-scoped attachment-size limits through DB
schema/migrations, shared company portability contracts, CLI
import/export coverage, and server attachment upload enforcement.
- Added productivity review service/API behavior for no-comment streak,
long-active, and high-churn review issues, including request-depth
clamping and issue summary exposure.
- Hardened issue ownership and recovery/control-plane paths: peer-agent
mutation denial, issue tree pause/resume behavior, stranded recovery
origins, and related activity/test coverage.
- Preserved related backend contract updates for routine timestamp
variables and managed agent instruction bundles because they live in
shared/server contracts from the source branch.
- Addressed Greptile feedback by making `Company.attachmentMaxBytes`
non-optional, simplifying review request-depth clamping, fixing the
migration final newline, and enforcing the process-level attachment cap
as the final ceiling for uploads.
- Added minimal company fixtures needed for repo-wide typecheck/build
and kept the PR to 66 changed files with forbidden/non-slice paths
excluded.
## Verification
- `pnpm install --frozen-lockfile`
- `git diff --check origin/master..HEAD`
- `git diff --name-only origin/master..HEAD | wc -l` -> 66 files
- `git diff --name-only origin/master..HEAD -- .github/workflows/pr.yml
pnpm-lock.yaml package.json doc skills .agents scripts
packages/adapters` -> no output
- `pnpm exec vitest run --config vitest.config.ts
packages/shared/src/validators/issue.test.ts
packages/shared/src/routine-variables.test.ts
packages/shared/src/adapter-types.test.ts
cli/src/__tests__/company-import-export-e2e.test.ts
cli/src/__tests__/company.test.ts
server/src/__tests__/productivity-review-service.test.ts
server/src/__tests__/issue-tree-control-service.test.ts
server/src/__tests__/issue-tree-control-routes.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/issue-attachment-routes.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts
server/src/__tests__/issues-service.test.ts` -> 12 files, 147 tests
passed
- `pnpm exec vitest run --config vitest.config.ts
cli/src/__tests__/company-delete.test.ts
cli/src/__tests__/company-import-export-e2e.test.ts
server/src/__tests__/productivity-review-service.test.ts` -> 3 files, 18
tests passed
- `pnpm exec vitest run --config vitest.config.ts
server/src/__tests__/issue-attachment-routes.test.ts` -> 1 file, 6 tests
passed
- `pnpm --filter @paperclipai/db typecheck && pnpm --filter
@paperclipai/shared typecheck && pnpm --filter @paperclipai/server
typecheck && pnpm --filter paperclipai typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck && pnpm --filter
@paperclipai/ui build`
## Risks
- Includes migrations `0073_shiny_salo.sql` and
`0074_striped_genesis.sql`; merge ordering matters if another PR adds
migrations first.
- This is intentionally backend-only apart from fixture/test updates
forced by shared type correctness; UI affordances from PR #4692 are not
present here and should land in separate UI slices.
- The worktree install emitted plugin SDK bin-link warnings for unbuilt
plugin packages, but the targeted tests and package typechecks completed
successfully.
> 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, GPT-5 coding agent, tool-enabled terminal/GitHub
workflow. Exact runtime context window was not exposed by the harness.
## 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>
2026-04-28 16:46:45 -05:00
|
|
|
|
|
|
|
|
it("clamps helper-created child requestDepth to the safe maximum", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const goalId = randomUUID();
|
|
|
|
|
const parentIssueId = 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(goals).values({
|
|
|
|
|
id: goalId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Ship child helpers",
|
|
|
|
|
level: "task",
|
|
|
|
|
status: "active",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
goalId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: parentIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
goalId,
|
|
|
|
|
title: "Parent issue",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
requestDepth: MAX_ISSUE_REQUEST_DEPTH,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { issue: child } = await svc.createChild(parentIssueId, {
|
|
|
|
|
title: "Child helper",
|
|
|
|
|
status: "todo",
|
|
|
|
|
requestDepth: MAX_ISSUE_REQUEST_DEPTH + 100,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(child.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
|
|
|
|
|
});
|
2026-03-30 14:08:44 -05:00
|
|
|
});
|
2026-04-04 13:56:04 -05:00
|
|
|
|
|
|
|
|
describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => {
|
|
|
|
|
let db!: ReturnType<typeof createDb>;
|
|
|
|
|
let svc!: ReturnType<typeof issueService>;
|
|
|
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-blockers-");
|
|
|
|
|
db = createDb(tempDb.connectionString);
|
|
|
|
|
svc = issueService(db);
|
|
|
|
|
await ensureIssueRelationsTable(db);
|
|
|
|
|
}, 20_000);
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await db.delete(issueComments);
|
|
|
|
|
await db.delete(issueRelations);
|
|
|
|
|
await db.delete(issueInboxArchives);
|
|
|
|
|
await db.delete(activityLog);
|
|
|
|
|
await db.delete(issues);
|
|
|
|
|
await db.delete(executionWorkspaces);
|
|
|
|
|
await db.delete(projectWorkspaces);
|
|
|
|
|
await db.delete(projects);
|
|
|
|
|
await db.delete(agents);
|
|
|
|
|
await db.delete(instanceSettings);
|
|
|
|
|
await db.delete(companies);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await tempDb?.cleanup();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const blockerId = randomUUID();
|
|
|
|
|
const blockedId = randomUUID();
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: blockerId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Blocker",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "high",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: blockedId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Blocked issue",
|
|
|
|
|
status: "blocked",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await svc.update(blockedId, {
|
|
|
|
|
blockedByIssueIds: [blockerId],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const blockerRelations = await svc.getRelationSummaries(blockerId);
|
|
|
|
|
const blockedRelations = await svc.getRelationSummaries(blockedId);
|
|
|
|
|
|
|
|
|
|
expect(blockerRelations.blocks.map((relation) => relation.id)).toEqual([blockedId]);
|
|
|
|
|
expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 15:50:32 -05:00
|
|
|
it("adds terminal blockers to immediate blocked-by summaries", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const issueA = randomUUID();
|
|
|
|
|
const issueB = randomUUID();
|
|
|
|
|
const issueC = randomUUID();
|
|
|
|
|
const issueD = randomUUID();
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{ id: issueA, companyId, identifier: "PAP-1", title: "Issue A", status: "blocked", priority: "medium" },
|
|
|
|
|
{ id: issueB, companyId, identifier: "PAP-2", title: "Issue B", status: "blocked", priority: "medium" },
|
|
|
|
|
{ id: issueC, companyId, identifier: "PAP-3", title: "Issue C", status: "blocked", priority: "medium" },
|
|
|
|
|
{ id: issueD, companyId, identifier: "PAP-4", title: "Issue D", status: "todo", priority: "high" },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await svc.update(issueC, { blockedByIssueIds: [issueD] });
|
|
|
|
|
await svc.update(issueB, { blockedByIssueIds: [issueC] });
|
|
|
|
|
await svc.update(issueA, { blockedByIssueIds: [issueB] });
|
|
|
|
|
|
|
|
|
|
const relations = await svc.getRelationSummaries(issueA);
|
|
|
|
|
|
|
|
|
|
expect(relations.blockedBy).toHaveLength(1);
|
|
|
|
|
expect(relations.blockedBy[0]).toMatchObject({
|
|
|
|
|
id: issueB,
|
|
|
|
|
identifier: "PAP-2",
|
|
|
|
|
title: "Issue B",
|
|
|
|
|
terminalBlockers: [
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
id: issueD,
|
|
|
|
|
identifier: "PAP-4",
|
|
|
|
|
title: "Issue D",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "high",
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-04 13:56:04 -05:00
|
|
|
it("rejects blocking cycles", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const issueA = randomUUID();
|
|
|
|
|
const issueB = randomUUID();
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{ id: issueA, companyId, title: "Issue A", status: "todo", priority: "medium" },
|
|
|
|
|
{ id: issueB, companyId, title: "Issue B", status: "todo", priority: "medium" },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await svc.update(issueA, { blockedByIssueIds: [issueB] });
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
svc.update(issueB, { blockedByIssueIds: [issueA] }),
|
|
|
|
|
).rejects.toMatchObject({ status: 422 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("only returns dependents once every blocker is done", 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: "CodexCoder",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
permissions: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const blockerA = randomUUID();
|
|
|
|
|
const blockerB = randomUUID();
|
|
|
|
|
const blockedIssueId = randomUUID();
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{ id: blockerA, companyId, title: "Blocker A", status: "done", priority: "medium" },
|
|
|
|
|
{ id: blockerB, companyId, title: "Blocker B", status: "todo", priority: "medium" },
|
|
|
|
|
{
|
|
|
|
|
id: blockedIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Blocked issue",
|
|
|
|
|
status: "blocked",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
assigneeAgentId,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await svc.update(blockedIssueId, { blockedByIssueIds: [blockerA, blockerB] });
|
|
|
|
|
|
|
|
|
|
expect(await svc.listWakeableBlockedDependents(blockerA)).toEqual([]);
|
|
|
|
|
|
|
|
|
|
await svc.update(blockerB, { status: "done" });
|
|
|
|
|
|
2026-04-04 20:00:29 -05:00
|
|
|
await expect(svc.listWakeableBlockedDependents(blockerA)).resolves.toEqual([
|
|
|
|
|
expect.objectContaining({
|
2026-04-04 13:56:04 -05:00
|
|
|
id: blockedIssueId,
|
|
|
|
|
assigneeAgentId,
|
2026-04-04 20:00:29 -05:00
|
|
|
blockerIssueIds: expect.arrayContaining([blockerA, blockerB]),
|
|
|
|
|
}),
|
2026-04-04 13:56:04 -05:00
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-20 16:03:57 -05:00
|
|
|
it("reports dependency readiness for blocked issue chains", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const blockerId = randomUUID();
|
|
|
|
|
const blockedId = randomUUID();
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{ id: blockerId, companyId, title: "Blocker", status: "todo", priority: "medium" },
|
|
|
|
|
{ id: blockedId, companyId, title: "Blocked", status: "todo", priority: "medium" },
|
|
|
|
|
]);
|
|
|
|
|
await svc.update(blockedId, { blockedByIssueIds: [blockerId] });
|
|
|
|
|
|
|
|
|
|
await expect(svc.getDependencyReadiness(blockedId)).resolves.toMatchObject({
|
|
|
|
|
issueId: blockedId,
|
|
|
|
|
blockerIssueIds: [blockerId],
|
|
|
|
|
unresolvedBlockerIssueIds: [blockerId],
|
|
|
|
|
unresolvedBlockerCount: 1,
|
|
|
|
|
allBlockersDone: false,
|
|
|
|
|
isDependencyReady: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await svc.update(blockerId, { status: "done" });
|
|
|
|
|
|
|
|
|
|
await expect(svc.getDependencyReadiness(blockedId)).resolves.toMatchObject({
|
|
|
|
|
issueId: blockedId,
|
|
|
|
|
blockerIssueIds: [blockerId],
|
|
|
|
|
unresolvedBlockerIssueIds: [],
|
|
|
|
|
unresolvedBlockerCount: 0,
|
|
|
|
|
allBlockersDone: true,
|
|
|
|
|
isDependencyReady: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
[codex] Roll up May 17 branch changes (#6210)
## Thinking Path
> - Paperclip is the control plane for autonomous AI companies, so agent
work needs visible ownership, recovery, and operator controls.
> - This local branch had accumulated several related control-plane
reliability and operator-experience fixes across recovery actions,
watchdog folding, model-profile defaults, mentions, markdown editing,
plugin launchers, and small UI polish.
> - The branch needed to be converted into a PR against the current
`origin/master` without losing dirty work or including lockfile/workflow
churn.
> - The safest standalone shape is a single rollup PR because the
recovery/server/UI files overlap heavily across the local commits and
splitting would create avoidable conflicts.
> - This pull request replays the local branch onto latest
`origin/master`, preserves the uncommitted work as logical commits, and
adds a Zod 4 validator compatibility fix found during verification.
> - The benefit is that the May 17 local branch can be reviewed and
merged as one coherent, conflict-free branch under the 100-file Greptile
limit.
## What Changed
- Rebased the local May 17 branch work onto current `origin/master` in a
dedicated worktree.
- Preserved and committed previously dirty changes for recovery retry
handling, plugin/sidebar launcher polish, and `.herenow` ignores.
- Added recovery-action behavior for returning source issues to `todo`
when retrying source-scoped recovery.
- Included the existing local recovery/liveness/watchdog fold, Codex
cheap-profile, markdown/mention, duplicate-agent, and UI polish commits
from the branch.
- Normalized shared validator `z.record(...)` schemas to explicit
string-key records for Zod 4 compatibility.
- Confirmed the PR has no `pnpm-lock.yaml` or `.github/workflows/*`
changes and stays below the 100-file Greptile limit.
## Verification
- `pnpm install --frozen-lockfile --ignore-scripts`
- `npm run install` in
`node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` to build the
local native sqlite3 binding after installing with scripts disabled
- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
packages/shared/src/project-mentions.test.ts
packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
server/src/__tests__/issue-recovery-actions.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
server/src/__tests__/plugin-local-folders.test.ts
ui/src/components/IssueRecoveryActionCard.test.tsx
ui/src/components/Sidebar.test.tsx
ui/src/components/SidebarAccountMenu.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/components/MarkdownBody.test.tsx
ui/src/lib/duplicate-agent-payload.test.ts
ui/src/pages/Routines.test.tsx`
- First pass: 13 files passed with 201 passing tests; 3 server files
failed before sqlite3 native binding was built.
- After rebuilding sqlite3:
`server/src/__tests__/heartbeat-model-profile.test.ts`,
`server/src/__tests__/issue-recovery-actions.test.ts`, and
`server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts`
passed/loaded; embedded Postgres tests were skipped by the local host
guard.
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
## Risks
- Medium risk: this is a broad rollup PR across recovery semantics,
server tests, shared validators, and UI surfaces.
- Some embedded Postgres tests skipped locally due the host guard, so CI
should provide the stronger database-backed signal.
- UI changes were covered by component tests, but no browser screenshot
was captured in this PR creation pass.
- This branch may overlap with existing recovery/liveness PR work; merge
this PR independently or restack/close overlapping branches rather than
merging duplicate implementations together.
> 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, GPT-5-based coding agent, tool-enabled local repository
and GitHub workflow, medium reasoning effort.
## 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
- [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>
2026-05-17 17:15:06 -05:00
|
|
|
it("unblocks a source issue when a liveness escalation recovery issue is marked done", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sourceIssueId = randomUUID();
|
|
|
|
|
const recoveryIssueId = randomUUID();
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: sourceIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Source issue",
|
|
|
|
|
status: "blocked",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: recoveryIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Liveness escalation issue",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
priority: "high",
|
|
|
|
|
originKind: "harness_liveness_escalation",
|
|
|
|
|
originId: `harness_liveness:${companyId}:${sourceIssueId}:invalid_review_participant:none`,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await svc.update(sourceIssueId, {
|
|
|
|
|
blockedByIssueIds: [recoveryIssueId],
|
|
|
|
|
});
|
|
|
|
|
await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({
|
|
|
|
|
blockedBy: [expect.objectContaining({ id: recoveryIssueId })],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await svc.update(recoveryIssueId, {
|
|
|
|
|
status: "done",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({
|
|
|
|
|
blockedBy: [],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-20 16:03:57 -05:00
|
|
|
it("rejects execution when unresolved blockers remain", 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: "CodexCoder",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
permissions: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const blockerId = randomUUID();
|
|
|
|
|
const blockedId = randomUUID();
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{ id: blockerId, companyId, title: "Blocker", status: "todo", priority: "medium" },
|
|
|
|
|
{
|
|
|
|
|
id: blockedId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Blocked",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
assigneeAgentId,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
await svc.update(blockedId, { blockedByIssueIds: [blockerId] });
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
svc.update(blockedId, { status: "in_progress" }),
|
|
|
|
|
).rejects.toMatchObject({ status: 422 });
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
svc.checkout(blockedId, assigneeAgentId, ["todo", "blocked"], null),
|
|
|
|
|
).rejects.toMatchObject({ status: 422 });
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-04 13:56:04 -05:00
|
|
|
it("wakes parents only when all direct children are terminal", 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: "CodexCoder",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
permissions: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const parentId = randomUUID();
|
|
|
|
|
const childA = randomUUID();
|
|
|
|
|
const childB = randomUUID();
|
|
|
|
|
await db.insert(issues).values([
|
|
|
|
|
{
|
|
|
|
|
id: parentId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Parent issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
assigneeAgentId,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: childA,
|
|
|
|
|
companyId,
|
|
|
|
|
parentId,
|
|
|
|
|
title: "Child A",
|
|
|
|
|
status: "done",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: childB,
|
|
|
|
|
companyId,
|
|
|
|
|
parentId,
|
|
|
|
|
title: "Child B",
|
|
|
|
|
status: "blocked",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toBeNull();
|
|
|
|
|
|
|
|
|
|
await svc.update(childB, { status: "cancelled" });
|
|
|
|
|
|
[codex] Add run liveness continuations (#4083)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - Heartbeat runs are the control-plane record of each agent execution
window.
> - Long-running local agents can exhaust context or stop while still
holding useful next-step state.
> - Operators need that stop reason, next action, and continuation path
to be durable and visible.
> - This pull request adds run liveness metadata, continuation
summaries, and UI surfaces for issue run ledgers.
> - The benefit is that interrupted or long-running work can resume with
clearer context instead of losing the agent's last useful handoff.
## What Changed
- Added heartbeat-run liveness fields, continuation attempt tracking,
and an idempotent `0058` migration.
- Added server services and tests for run liveness, continuation
summaries, stop metadata, and activity backfill.
- Wired local and HTTP adapters to surface continuation/liveness context
through shared adapter utilities.
- Added shared constants, validators, and heartbeat types for liveness
continuation state.
- Added issue-detail UI surfaces for continuation handoffs and the run
ledger, with component tests.
- Updated agent runtime docs, heartbeat protocol docs, prompt guidance,
onboarding assets, and skills instructions to explain continuation
behavior.
- Addressed Greptile feedback by scoping document evidence by run,
excluding system continuation-summary documents from liveness evidence,
importing shared liveness types, surfacing hidden ledger run counts,
documenting bounded retry behavior, and moving run-ledger liveness
backfill off the request path.
## Verification
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/run-continuations.test.ts
server/src/__tests__/run-liveness.test.ts
server/src/__tests__/activity-service.test.ts
server/src/__tests__/documents-service.test.ts
server/src/__tests__/issue-continuation-summary.test.ts
server/src/services/heartbeat-stop-metadata.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/components/IssueContinuationHandoff.test.tsx
ui/src/components/IssueDocumentsSection.test.tsx`
- `pnpm --filter @paperclipai/db build`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/run-continuations.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "treats a
plan document update"`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts -t "activity
service|treats a plan document update"`
- Remote PR checks on head `e53b1a1d`: `verify`, `e2e`, `policy`, and
Snyk all passed.
- Confirmed `public-gh/master` is an ancestor of this branch after
fetching `public-gh master`.
- Confirmed `pnpm-lock.yaml` is not included in the branch diff.
- Confirmed migration `0058_wealthy_starbolt.sql` is ordered after
`0057` and uses `IF NOT EXISTS` guards for repeat application.
- Greptile inline review threads are resolved.
## Risks
- Medium risk: this touches heartbeat execution, liveness recovery,
activity rendering, issue routes, shared contracts, docs, and UI.
- Migration risk is mitigated by additive columns/indexes and idempotent
guards.
- Run-ledger liveness backfill is now asynchronous, so the first ledger
response can briefly show historical missing liveness until the
background backfill completes.
- UI screenshot coverage is not included in this packaging pass;
validation is currently through focused component tests.
> 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, GPT-5.4, local tool-use coding agent with terminal, git,
GitHub connector, GitHub CLI, and Paperclip API access.
## 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
Screenshot note: no before/after screenshots were captured in this PR
packaging pass; the UI changes are covered by focused component tests
listed above.
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 06:01:49 -05:00
|
|
|
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toMatchObject({
|
2026-04-04 13:56:04 -05:00
|
|
|
id: parentId,
|
|
|
|
|
assigneeAgentId,
|
|
|
|
|
childIssueIds: [childA, childB],
|
[codex] Add run liveness continuations (#4083)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - Heartbeat runs are the control-plane record of each agent execution
window.
> - Long-running local agents can exhaust context or stop while still
holding useful next-step state.
> - Operators need that stop reason, next action, and continuation path
to be durable and visible.
> - This pull request adds run liveness metadata, continuation
summaries, and UI surfaces for issue run ledgers.
> - The benefit is that interrupted or long-running work can resume with
clearer context instead of losing the agent's last useful handoff.
## What Changed
- Added heartbeat-run liveness fields, continuation attempt tracking,
and an idempotent `0058` migration.
- Added server services and tests for run liveness, continuation
summaries, stop metadata, and activity backfill.
- Wired local and HTTP adapters to surface continuation/liveness context
through shared adapter utilities.
- Added shared constants, validators, and heartbeat types for liveness
continuation state.
- Added issue-detail UI surfaces for continuation handoffs and the run
ledger, with component tests.
- Updated agent runtime docs, heartbeat protocol docs, prompt guidance,
onboarding assets, and skills instructions to explain continuation
behavior.
- Addressed Greptile feedback by scoping document evidence by run,
excluding system continuation-summary documents from liveness evidence,
importing shared liveness types, surfacing hidden ledger run counts,
documenting bounded retry behavior, and moving run-ledger liveness
backfill off the request path.
## Verification
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/run-continuations.test.ts
server/src/__tests__/run-liveness.test.ts
server/src/__tests__/activity-service.test.ts
server/src/__tests__/documents-service.test.ts
server/src/__tests__/issue-continuation-summary.test.ts
server/src/services/heartbeat-stop-metadata.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/components/IssueContinuationHandoff.test.tsx
ui/src/components/IssueDocumentsSection.test.tsx`
- `pnpm --filter @paperclipai/db build`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/run-continuations.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "treats a
plan document update"`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts -t "activity
service|treats a plan document update"`
- Remote PR checks on head `e53b1a1d`: `verify`, `e2e`, `policy`, and
Snyk all passed.
- Confirmed `public-gh/master` is an ancestor of this branch after
fetching `public-gh master`.
- Confirmed `pnpm-lock.yaml` is not included in the branch diff.
- Confirmed migration `0058_wealthy_starbolt.sql` is ordered after
`0057` and uses `IF NOT EXISTS` guards for repeat application.
- Greptile inline review threads are resolved.
## Risks
- Medium risk: this touches heartbeat execution, liveness recovery,
activity rendering, issue routes, shared contracts, docs, and UI.
- Migration risk is mitigated by additive columns/indexes and idempotent
guards.
- Run-ledger liveness backfill is now asynchronous, so the first ledger
response can briefly show historical missing liveness until the
background backfill completes.
- UI screenshot coverage is not included in this packaging pass;
validation is currently through focused component tests.
> 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, GPT-5.4, local tool-use coding agent with terminal, git,
GitHub connector, GitHub CLI, and Paperclip API access.
## 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
Screenshot note: no before/after screenshots were captured in this PR
packaging pass; the UI changes are covered by focused component tests
listed above.
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 06:01:49 -05:00
|
|
|
childIssueSummaries: [
|
|
|
|
|
expect.objectContaining({ id: childA, title: "Child A", status: "done" }),
|
|
|
|
|
expect.objectContaining({ id: childB, title: "Child B", status: "cancelled" }),
|
|
|
|
|
],
|
|
|
|
|
childIssueSummaryTruncated: false,
|
2026-04-04 13:56:04 -05:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
Sync/master post pap1497 followups 2026 04 15 (#3779)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The board depends on issue, inbox, cost, and company-skill surfaces
to stay accurate and fast while agents are actively working
> - The PAP-1497 follow-up branch exposed a few rough edges in those
surfaces: stale active-run state on completed issues, missing creator
filters, oversized issue payload scans, and placeholder issue-route
parsing
> - Those gaps make the control plane harder to trust because operators
can see misleading run state, miss the right subset of work, or pay
extra query/render cost on large issue records
> - This pull request tightens those follow-ups across server and UI
code, and adds regression coverage for the affected paths
> - The benefit is a more reliable issue workflow, safer high-volume
cost aggregation, and clearer board/operator navigation
## What Changed
- Added the `v2026.415.0` release changelog entry.
- Fixed stale issue-run presentation after completion and reused the
shared issue-path parser so literal route placeholders no longer become
issue links.
- Added creator filters to the Issues page and Inbox, including
persisted filter-state normalization and regression coverage.
- Bounded issue detail/list project-mention scans and trimmed large
issue-list payload fields to keep issue reads lighter.
- Hardened company-skill list projection and cost/finance aggregation so
large markdown blobs and large summed values do not leak into list
responses or overflow 32-bit casts.
- Added targeted server/UI regression tests for company skills,
costs/finance, issue mention scanning, creator filters, inbox
normalization, and issue reference parsing.
## Verification
- `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/costs-service.test.ts
server/src/__tests__/issues-goal-context-routes.test.ts
server/src/__tests__/issues-service.test.ts ui/src/lib/inbox.test.ts
ui/src/lib/issue-filters.test.ts ui/src/lib/issue-reference.test.ts`
- `gh pr checks 3779`
Current pass set on the PR head: `policy`, `verify`, `e2e`,
`security/snyk (cryppadotta)`, `Greptile Review`
## Risks
- Creator filter options are derived from the currently loaded
issue/agent data, so very sparse result sets may not surface every
historical creator until they appear in the active dataset.
- Cost/finance aggregate casts now use `double precision`; that removes
the current overflow risk, but future schema changes should keep
large-value aggregation behavior under review.
- Issue detail mention scanning now skips comment-body scans on the
detail route, so any consumer that relied on comment-only project
mentions there would need to fetch them separately.
## Model Used
- OpenAI Codex, GPT-5-based coding agent with terminal tool use and
local code execution in the Paperclip workspace. Exact internal model
ID/context-window exposure is not surfaced in this session.
## 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 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
- [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>
2026-04-15 21:13:56 -05:00
|
|
|
|
feat: implement multi-user access and invite flows (#3784)
## Thinking Path
> - Paperclip is the control plane for autonomous AI companies.
> - V1 needs to stay local-first while also supporting shared,
authenticated deployments.
> - Human operators need real identities, company membership, invite
flows, profile surfaces, and company-scoped access controls.
> - Agents and operators also need the existing issue, inbox, workspace,
approval, and plugin flows to keep working under those authenticated
boundaries.
> - This branch accumulated the multi-user implementation, follow-up QA
fixes, workspace/runtime refinements, invite UX improvements,
release-branch conflict resolution, and review hardening.
> - This pull request consolidates that branch onto the current `master`
branch as a single reviewable PR.
> - The benefit is a complete multi-user implementation path with tests
and docs carried forward without dropping existing branch work.
## What Changed
- Added authenticated human-user access surfaces: auth/session routes,
company user directory, profile settings, company access/member
management, join requests, and invite management.
- Added invite creation, invite landing, onboarding, logo/branding,
invite grants, deduped join requests, and authenticated multi-user E2E
coverage.
- Tightened company-scoped and instance-admin authorization across
board, plugin, adapter, access, issue, and workspace routes.
- Added profile-image URL validation hardening, avatar preservation on
name-only profile updates, and join-request uniqueness migration cleanup
for pending human requests.
- Added an atomic member role/status/grants update path so Company
Access saves no longer leave partially updated permissions.
- Improved issue chat, inbox, assignee identity rendering,
sidebar/account/company navigation, workspace routing, and execution
workspace reuse behavior for multi-user operation.
- Added and updated server/UI tests covering auth, invites, membership,
issue workspace inheritance, plugin authz, inbox/chat behavior, and
multi-user flows.
- Merged current `public-gh/master` into this branch, resolved all
conflicts, and verified no `pnpm-lock.yaml` change is included in this
PR diff.
## Verification
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts
ui/src/components/IssueChatThread.test.tsx ui/src/pages/Inbox.test.tsx`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/plugin-routes-authz.test.ts`
- `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/workspace-runtime-service-authz.test.ts
server/src/__tests__/access-validators.test.ts`
- `pnpm exec vitest run
server/src/__tests__/authz-company-access.test.ts
server/src/__tests__/routines-routes.test.ts
server/src/__tests__/sidebar-preferences-routes.test.ts
server/src/__tests__/approval-routes-idempotency.test.ts
server/src/__tests__/openclaw-invite-prompt-route.test.ts
server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
server/src/__tests__/routines-e2e.test.ts`
- `pnpm exec vitest run server/src/__tests__/auth-routes.test.ts
ui/src/pages/CompanyAccess.test.tsx`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/db typecheck && pnpm --filter @paperclipai/server
typecheck`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm db:generate`
- `npx playwright test --config tests/e2e/playwright.config.ts --list`
- Confirmed branch has no uncommitted changes and is `0` commits behind
`public-gh/master` before PR creation.
- Confirmed no `pnpm-lock.yaml` change is staged or present in the PR
diff.
## Risks
- High review surface area: this PR contains the accumulated multi-user
branch plus follow-up fixes, so reviewers should focus especially on
company-boundary enforcement and authenticated-vs-local deployment
behavior.
- UI behavior changed across invites, inbox, issue chat, access
settings, and sidebar navigation; no browser screenshots are included in
this branch-consolidation PR.
- Plugin install, upgrade, and lifecycle/config mutations now require
instance-admin access, which is intentional but may change expectations
for non-admin board users.
- A join-request dedupe migration rejects duplicate pending human
requests before creating unique indexes; deployments with unusual
historical duplicates should review the migration behavior.
- Company member role/status/grant saves now use a new combined
endpoint; older separate endpoints remain for compatibility.
- Full production build was not run locally in this heartbeat; CI should
cover the full matrix.
## Model Used
- OpenAI Codex coding agent, GPT-5-based model, CLI/tool-use
environment. Exact deployed model identifier and context window were not
exposed by the runtime.
## 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 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
Note on screenshots: this is a branch-consolidation PR for an
already-developed multi-user branch, and no browser screenshots were
captured during this heartbeat.
---------
Co-authored-by: dotta <dotta@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 09:44:19 -05:00
|
|
|
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
|
|
|
|
let db!: ReturnType<typeof createDb>;
|
|
|
|
|
let svc!: ReturnType<typeof issueService>;
|
|
|
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
|
|
|
|
|
db = createDb(tempDb.connectionString);
|
|
|
|
|
svc = issueService(db);
|
|
|
|
|
await ensureIssueRelationsTable(db);
|
|
|
|
|
}, 20_000);
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await db.delete(issueComments);
|
|
|
|
|
await db.delete(issueRelations);
|
|
|
|
|
await db.delete(issueInboxArchives);
|
|
|
|
|
await db.delete(activityLog);
|
|
|
|
|
await db.delete(issues);
|
|
|
|
|
await db.delete(executionWorkspaces);
|
|
|
|
|
await db.delete(projectWorkspaces);
|
|
|
|
|
await db.delete(projects);
|
|
|
|
|
await db.delete(agents);
|
|
|
|
|
await db.delete(instanceSettings);
|
|
|
|
|
await db.delete(companies);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await tempDb?.cleanup();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const parentIssueId = 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 instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Primary workspace",
|
|
|
|
|
isPrimary: true,
|
|
|
|
|
sharedWorkspaceKey: "workspace-key",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
|
|
|
id: executionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
strategyType: "git_worktree",
|
|
|
|
|
name: "Issue worktree",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "git_worktree",
|
|
|
|
|
providerRef: `/tmp/${executionWorkspaceId}`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: parentIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
title: "Parent issue",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
executionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: {
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
workspaceRuntime: { profile: "agent" },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const child = await svc.create(companyId, {
|
|
|
|
|
parentId: parentIssueId,
|
|
|
|
|
projectId,
|
|
|
|
|
title: "Child issue",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
|
|
|
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
|
|
|
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
|
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
|
|
|
expect(child.executionWorkspaceSettings).toEqual({
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
workspaceRuntime: { profile: "agent" },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const parentIssueId = randomUUID();
|
|
|
|
|
const parentProjectWorkspaceId = randomUUID();
|
|
|
|
|
const parentExecutionWorkspaceId = randomUUID();
|
|
|
|
|
const explicitProjectWorkspaceId = randomUUID();
|
|
|
|
|
const explicitExecutionWorkspaceId = 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: true });
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values([
|
|
|
|
|
{
|
|
|
|
|
id: parentProjectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Parent workspace",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: explicitProjectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Explicit workspace",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(executionWorkspaces).values([
|
|
|
|
|
{
|
|
|
|
|
id: parentExecutionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
strategyType: "git_worktree",
|
|
|
|
|
name: "Parent worktree",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "git_worktree",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: explicitExecutionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId: explicitProjectWorkspaceId,
|
|
|
|
|
mode: "shared_workspace",
|
|
|
|
|
strategyType: "project_primary",
|
|
|
|
|
name: "Explicit shared workspace",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "local_fs",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: parentIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
|
|
|
title: "Parent issue",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
executionWorkspaceId: parentExecutionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: {
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const child = await svc.create(companyId, {
|
|
|
|
|
parentId: parentIssueId,
|
|
|
|
|
projectId,
|
|
|
|
|
title: "Child issue",
|
|
|
|
|
projectWorkspaceId: explicitProjectWorkspaceId,
|
|
|
|
|
executionWorkspaceId: explicitExecutionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: {
|
|
|
|
|
mode: "shared_workspace",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId);
|
|
|
|
|
expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId);
|
|
|
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
|
|
|
expect(child.executionWorkspaceSettings).toEqual({
|
|
|
|
|
mode: "shared_workspace",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const sourceIssueId = 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 instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Primary workspace",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
|
|
|
id: executionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
mode: "operator_branch",
|
|
|
|
|
strategyType: "git_worktree",
|
|
|
|
|
name: "Operator branch",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "git_worktree",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: sourceIssueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
title: "Source issue",
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
executionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: {
|
|
|
|
|
mode: "operator_branch",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const followUp = await svc.create(companyId, {
|
|
|
|
|
projectId,
|
|
|
|
|
title: "Follow-up issue",
|
|
|
|
|
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(followUp.parentId).toBeNull();
|
|
|
|
|
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
|
|
|
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
|
|
|
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
|
|
|
|
|
expect(followUp.executionWorkspaceSettings).toEqual({
|
|
|
|
|
mode: "operator_branch",
|
|
|
|
|
});
|
|
|
|
|
});
|
Fix runtime state race, workspace sync, plugin startup, and orphaned leases (#4804)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Agents run inside environments that are leased, and the server
manages runtime state, workspace configuration, and plugin lifecycle
> - Several edge cases caused failures during concurrent operations: a
race condition in runtime state insertion could produce duplicate-key
errors, reused workspaces didn't sync their configuration when the
parent issue was updated, sandbox provider plugins could be queried
before registration completed, and orphaned environment leases from
failed runs were never released
> - This PR fixes these four runtime/environment issues
> - The benefit is more reliable concurrent agent execution and proper
resource cleanup
## What Changed
- `services/heartbeat.ts`: Fixed a race condition where concurrent
runtime state inserts could fail with a duplicate-key error by using an
upsert pattern
- `services/issues.ts`: Sync reused workspace configuration when an
issue is updated, so the workspace reflects the latest issue state
- `services/environment-runtime.ts`: Fixed a startup race where sandbox
provider plugins could be queried before registration completed, by
awaiting plugin readiness before resolving environment drivers
- `services/heartbeat.ts`: Release environment leases for orphaned runs
that lost their process without cleanup
## Verification
- `pnpm test` — all existing and new tests pass, including new tests for
runtime state upsert and process recovery lease cleanup
- `pnpm typecheck` — clean
- Manual: trigger concurrent agent runs to verify no duplicate-key
failures; verify orphaned leases are released after process loss
## Risks
- Low risk. The runtime state upsert changes insert-to-upsert behavior,
which could mask a legitimate duplicate if two different runs produce
the same key — but this is prevented by the run ID being part of the
key. The plugin startup await is bounded by the existing registration
timeout.
## Model Used
Codex GPT 5.4 high via Paperclip.
## 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
- [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
2026-04-29 16:37:10 -07:00
|
|
|
|
|
|
|
|
it("syncs reused execution workspace config when issue workspace settings are updated", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const projectWorkspaceId = randomUUID();
|
|
|
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
|
const issueId = 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: true });
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Workspace project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Primary workspace",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
|
|
|
id: executionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
strategyType: "git_worktree",
|
|
|
|
|
name: "Issue worktree",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "git_worktree",
|
|
|
|
|
metadata: {
|
|
|
|
|
config: {
|
|
|
|
|
environmentId: "env-old",
|
|
|
|
|
provisionCommand: "bash ./scripts/provision-old.sh",
|
|
|
|
|
teardownCommand: "bash ./scripts/teardown-old.sh",
|
|
|
|
|
workspaceRuntime: { profile: "old" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: issueId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
title: "Recovery issue",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
executionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: {
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
environmentId: "env-old",
|
|
|
|
|
workspaceStrategy: {
|
|
|
|
|
type: "git_worktree",
|
|
|
|
|
provisionCommand: "bash ./scripts/provision-old.sh",
|
|
|
|
|
teardownCommand: "bash ./scripts/teardown-old.sh",
|
|
|
|
|
},
|
|
|
|
|
workspaceRuntime: { profile: "old" },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await svc.update(issueId, {
|
|
|
|
|
executionWorkspaceSettings: {
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
environmentId: "env-new",
|
|
|
|
|
workspaceStrategy: {
|
|
|
|
|
type: "cloud_sandbox",
|
|
|
|
|
provisionCommand: "bash ./scripts/provision-new.sh",
|
|
|
|
|
teardownCommand: "bash ./scripts/teardown-new.sh",
|
|
|
|
|
},
|
|
|
|
|
workspaceRuntime: { profile: "new" },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const workspace = await db
|
|
|
|
|
.select({ metadata: executionWorkspaces.metadata })
|
|
|
|
|
.from(executionWorkspaces)
|
|
|
|
|
.where(eq(executionWorkspaces.id, executionWorkspaceId))
|
|
|
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
|
|
|
|
|
|
expect(workspace?.metadata).toEqual({
|
|
|
|
|
config: {
|
|
|
|
|
environmentId: "env-new",
|
|
|
|
|
provisionCommand: "bash ./scripts/provision-new.sh",
|
|
|
|
|
teardownCommand: "bash ./scripts/teardown-new.sh",
|
|
|
|
|
cleanupCommand: null,
|
|
|
|
|
workspaceRuntime: { profile: "new" },
|
|
|
|
|
desiredState: null,
|
|
|
|
|
serviceStates: null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
feat: implement multi-user access and invite flows (#3784)
## Thinking Path
> - Paperclip is the control plane for autonomous AI companies.
> - V1 needs to stay local-first while also supporting shared,
authenticated deployments.
> - Human operators need real identities, company membership, invite
flows, profile surfaces, and company-scoped access controls.
> - Agents and operators also need the existing issue, inbox, workspace,
approval, and plugin flows to keep working under those authenticated
boundaries.
> - This branch accumulated the multi-user implementation, follow-up QA
fixes, workspace/runtime refinements, invite UX improvements,
release-branch conflict resolution, and review hardening.
> - This pull request consolidates that branch onto the current `master`
branch as a single reviewable PR.
> - The benefit is a complete multi-user implementation path with tests
and docs carried forward without dropping existing branch work.
## What Changed
- Added authenticated human-user access surfaces: auth/session routes,
company user directory, profile settings, company access/member
management, join requests, and invite management.
- Added invite creation, invite landing, onboarding, logo/branding,
invite grants, deduped join requests, and authenticated multi-user E2E
coverage.
- Tightened company-scoped and instance-admin authorization across
board, plugin, adapter, access, issue, and workspace routes.
- Added profile-image URL validation hardening, avatar preservation on
name-only profile updates, and join-request uniqueness migration cleanup
for pending human requests.
- Added an atomic member role/status/grants update path so Company
Access saves no longer leave partially updated permissions.
- Improved issue chat, inbox, assignee identity rendering,
sidebar/account/company navigation, workspace routing, and execution
workspace reuse behavior for multi-user operation.
- Added and updated server/UI tests covering auth, invites, membership,
issue workspace inheritance, plugin authz, inbox/chat behavior, and
multi-user flows.
- Merged current `public-gh/master` into this branch, resolved all
conflicts, and verified no `pnpm-lock.yaml` change is included in this
PR diff.
## Verification
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts
ui/src/components/IssueChatThread.test.tsx ui/src/pages/Inbox.test.tsx`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/plugin-routes-authz.test.ts`
- `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/workspace-runtime-service-authz.test.ts
server/src/__tests__/access-validators.test.ts`
- `pnpm exec vitest run
server/src/__tests__/authz-company-access.test.ts
server/src/__tests__/routines-routes.test.ts
server/src/__tests__/sidebar-preferences-routes.test.ts
server/src/__tests__/approval-routes-idempotency.test.ts
server/src/__tests__/openclaw-invite-prompt-route.test.ts
server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
server/src/__tests__/routines-e2e.test.ts`
- `pnpm exec vitest run server/src/__tests__/auth-routes.test.ts
ui/src/pages/CompanyAccess.test.tsx`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/db typecheck && pnpm --filter @paperclipai/server
typecheck`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm db:generate`
- `npx playwright test --config tests/e2e/playwright.config.ts --list`
- Confirmed branch has no uncommitted changes and is `0` commits behind
`public-gh/master` before PR creation.
- Confirmed no `pnpm-lock.yaml` change is staged or present in the PR
diff.
## Risks
- High review surface area: this PR contains the accumulated multi-user
branch plus follow-up fixes, so reviewers should focus especially on
company-boundary enforcement and authenticated-vs-local deployment
behavior.
- UI behavior changed across invites, inbox, issue chat, access
settings, and sidebar navigation; no browser screenshots are included in
this branch-consolidation PR.
- Plugin install, upgrade, and lifecycle/config mutations now require
instance-admin access, which is intentional but may change expectations
for non-admin board users.
- A join-request dedupe migration rejects duplicate pending human
requests before creating unique indexes; deployments with unusual
historical duplicates should review the migration behavior.
- Company member role/status/grant saves now use a new combined
endpoint; older separate endpoints remain for compatibility.
- Full production build was not run locally in this heartbeat; CI should
cover the full matrix.
## Model Used
- OpenAI Codex coding agent, GPT-5-based model, CLI/tool-use
environment. Exact deployed model identifier and context window were not
exposed by the runtime.
## 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 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
Note on screenshots: this is a branch-consolidation PR for an
already-developed multi-user branch, and no browser screenshots were
captured during this heartbeat.
---------
Co-authored-by: dotta <dotta@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 09:44:19 -05:00
|
|
|
});
|
|
|
|
|
|
Sync/master post pap1497 followups 2026 04 15 (#3779)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The board depends on issue, inbox, cost, and company-skill surfaces
to stay accurate and fast while agents are actively working
> - The PAP-1497 follow-up branch exposed a few rough edges in those
surfaces: stale active-run state on completed issues, missing creator
filters, oversized issue payload scans, and placeholder issue-route
parsing
> - Those gaps make the control plane harder to trust because operators
can see misleading run state, miss the right subset of work, or pay
extra query/render cost on large issue records
> - This pull request tightens those follow-ups across server and UI
code, and adds regression coverage for the affected paths
> - The benefit is a more reliable issue workflow, safer high-volume
cost aggregation, and clearer board/operator navigation
## What Changed
- Added the `v2026.415.0` release changelog entry.
- Fixed stale issue-run presentation after completion and reused the
shared issue-path parser so literal route placeholders no longer become
issue links.
- Added creator filters to the Issues page and Inbox, including
persisted filter-state normalization and regression coverage.
- Bounded issue detail/list project-mention scans and trimmed large
issue-list payload fields to keep issue reads lighter.
- Hardened company-skill list projection and cost/finance aggregation so
large markdown blobs and large summed values do not leak into list
responses or overflow 32-bit casts.
- Added targeted server/UI regression tests for company skills,
costs/finance, issue mention scanning, creator filters, inbox
normalization, and issue reference parsing.
## Verification
- `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/costs-service.test.ts
server/src/__tests__/issues-goal-context-routes.test.ts
server/src/__tests__/issues-service.test.ts ui/src/lib/inbox.test.ts
ui/src/lib/issue-filters.test.ts ui/src/lib/issue-reference.test.ts`
- `gh pr checks 3779`
Current pass set on the PR head: `policy`, `verify`, `e2e`,
`security/snyk (cryppadotta)`, `Greptile Review`
## Risks
- Creator filter options are derived from the currently loaded
issue/agent data, so very sparse result sets may not surface every
historical creator until they appear in the active dataset.
- Cost/finance aggregate casts now use `double precision`; that removes
the current overflow risk, but future schema changes should keep
large-value aggregation behavior under review.
- Issue detail mention scanning now skips comment-body scans on the
detail route, so any consumer that relied on comment-only project
mentions there would need to fetch them separately.
## Model Used
- OpenAI Codex, GPT-5-based coding agent with terminal tool use and
local code execution in the Paperclip workspace. Exact internal model
ID/context-window exposure is not surfaced in this session.
## 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 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
- [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>
2026-04-15 21:13:56 -05:00
|
|
|
describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => {
|
|
|
|
|
let db!: ReturnType<typeof createDb>;
|
|
|
|
|
let svc!: ReturnType<typeof issueService>;
|
|
|
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-mentioned-projects-");
|
|
|
|
|
db = createDb(tempDb.connectionString);
|
|
|
|
|
svc = issueService(db);
|
|
|
|
|
await ensureIssueRelationsTable(db);
|
|
|
|
|
}, 20_000);
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await db.delete(issueComments);
|
|
|
|
|
await db.delete(issueRelations);
|
|
|
|
|
await db.delete(issueInboxArchives);
|
|
|
|
|
await db.delete(activityLog);
|
|
|
|
|
await db.delete(issues);
|
|
|
|
|
await db.delete(executionWorkspaces);
|
|
|
|
|
await db.delete(projectWorkspaces);
|
|
|
|
|
await db.delete(projects);
|
|
|
|
|
await db.delete(agents);
|
|
|
|
|
await db.delete(instanceSettings);
|
|
|
|
|
await db.delete(companies);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await tempDb?.cleanup();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("can skip comment-body scans for bounded issue detail reads", async () => {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const issueId = randomUUID();
|
|
|
|
|
const titleProjectId = randomUUID();
|
|
|
|
|
const commentProjectId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values([
|
|
|
|
|
{
|
|
|
|
|
id: titleProjectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Title project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: commentProjectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Comment project",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: issueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: `Link [Title](${buildProjectMentionHref(titleProjectId)})`,
|
|
|
|
|
description: null,
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(issueComments).values({
|
|
|
|
|
companyId,
|
|
|
|
|
issueId,
|
|
|
|
|
body: `Comment link [Comment](${buildProjectMentionHref(commentProjectId)})`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(await svc.findMentionedProjectIds(issueId, { includeCommentBodies: false })).toEqual([titleProjectId]);
|
|
|
|
|
expect(await svc.findMentionedProjectIds(issueId)).toEqual([
|
|
|
|
|
titleProjectId,
|
|
|
|
|
commentProjectId,
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
[codex] Fix stale issue execution run locks (#4258)
## Thinking Path
> - Paperclip is a control plane for AI-agent companies, so issue
checkout and execution ownership are core safety contracts.
> - The affected subsystem is the issue service and route layer that
gates agent writes by `checkoutRunId` and `executionRunId`.
> - PAP-1982 exposed a stale-lock failure mode where a terminal
heartbeat run could leave `executionRunId` pinned after checkout
ownership had moved or been cleared.
> - That stale execution lock could reject legitimate
PATCH/comment/release requests from the rightful assignee after a
harness restart.
> - This pull request centralizes terminal-run cleanup, applies it
before ownership-gated writes, and adds a board-only recovery endpoint
for operator intervention.
> - The benefit is that crashed or terminal runs no longer strand issues
behind stale execution locks, while live execution locks still block
conflicting writes.
## What Changed
- Added `issueService.clearExecutionRunIfTerminal()` to atomically lock
the issue/run rows and clear terminal or missing execution-run locks.
- Reused stale execution-lock cleanup from checkout,
`assertCheckoutOwner()`, and `release()`.
- Allowed the same assigned agent/current run to adopt an unowned
`in_progress` checkout after stale execution-lock cleanup.
- Updated release to clear `executionRunId`, `executionAgentNameKey`,
and `executionLockedAt`.
- Added board-only `POST /api/issues/:id/admin/force-release` with
company access checks, optional `clearAssignee=true`, and
`issue.admin_force_release` audit logging.
- Added embedded Postgres service tests and route integration tests for
stale-lock recovery, release behavior, and admin force-release
authorization/audit behavior.
- Documented the new force-release API in `doc/SPEC-implementation.md`.
## Verification
- `pnpm vitest run server/src/__tests__/issues-service.test.ts
server/src/__tests__/issue-stale-execution-lock-routes.test.ts` passed.
- `pnpm vitest run
server/src/__tests__/issue-stale-execution-lock-routes.test.ts
server/src/__tests__/approval-routes-idempotency.test.ts
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/issue-telemetry-routes.test.ts` passed.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- `git diff --check` passed.
- `pnpm lint` could not run because this repo has no `lint` command.
- Full `pnpm test:run` completed with 4 failures in existing route
suites: `approval-routes-idempotency.test.ts` (2),
`issue-comment-reopen-routes.test.ts` (1), and
`issue-telemetry-routes.test.ts` (1). Those same files pass when run
isolated and when run together with the new stale-lock route test, so
this appears to be a whole-suite ordering/mock-isolation issue outside
this patch path.
## Risks
- Medium: this changes ownership-gated write behavior. The new adoption
path is limited to the current run, the current assignee, `in_progress`
issues, and rows with no checkout owner after terminal-lock cleanup.
- Low: the admin force-release endpoint is board-only and
company-scoped, but misuse can intentionally clear a live lock. It
writes an audit event with prior lock IDs.
- No schema or migration changes.
> 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, GPT-5 coding agent (`gpt-5`), agentic coding with
terminal/tool use and local test execution.
## 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
2026-04-22 10:43:38 -05:00
|
|
|
|
|
|
|
|
describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => {
|
|
|
|
|
let db!: ReturnType<typeof createDb>;
|
|
|
|
|
let svc!: ReturnType<typeof issueService>;
|
|
|
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-execution-lock-");
|
|
|
|
|
db = createDb(tempDb.connectionString);
|
|
|
|
|
svc = issueService(db);
|
|
|
|
|
}, 20_000);
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await db.delete(issueComments);
|
|
|
|
|
await db.delete(issueRelations);
|
|
|
|
|
await db.delete(issueInboxArchives);
|
|
|
|
|
await db.delete(activityLog);
|
|
|
|
|
await db.delete(issues);
|
|
|
|
|
await db.delete(heartbeatRuns);
|
|
|
|
|
await db.delete(executionWorkspaces);
|
|
|
|
|
await db.delete(projectWorkspaces);
|
|
|
|
|
await db.delete(projects);
|
|
|
|
|
await db.delete(goals);
|
|
|
|
|
await db.delete(agents);
|
|
|
|
|
await db.delete(instanceSettings);
|
|
|
|
|
await db.delete(companies);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await tempDb?.cleanup();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function seedIssueWithRun(status: string | null) {
|
|
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const agentId = randomUUID();
|
|
|
|
|
const issueId = randomUUID();
|
|
|
|
|
const runId = status ? randomUUID() : null;
|
|
|
|
|
|
|
|
|
|
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: agentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "CodexCoder",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
permissions: {},
|
|
|
|
|
});
|
|
|
|
|
if (runId) {
|
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
|
|
|
id: runId,
|
|
|
|
|
companyId,
|
|
|
|
|
agentId,
|
|
|
|
|
status,
|
|
|
|
|
invocationSource: "manual",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
await db.insert(issues).values({
|
|
|
|
|
id: issueId,
|
|
|
|
|
companyId,
|
|
|
|
|
title: "Execution lock",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
priority: "medium",
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
executionRunId: runId,
|
|
|
|
|
executionAgentNameKey: runId ? "codexcoder" : null,
|
|
|
|
|
executionLockedAt: runId ? new Date() : null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { issueId, runId };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("clears execution locks owned by terminal runs", async () => {
|
|
|
|
|
const { issueId } = await seedIssueWithRun("failed");
|
|
|
|
|
|
|
|
|
|
await expect(svc.clearExecutionRunIfTerminal(issueId)).resolves.toBe(true);
|
|
|
|
|
|
|
|
|
|
const row = await db
|
|
|
|
|
.select({
|
|
|
|
|
executionRunId: issues.executionRunId,
|
|
|
|
|
executionAgentNameKey: issues.executionAgentNameKey,
|
|
|
|
|
executionLockedAt: issues.executionLockedAt,
|
|
|
|
|
})
|
|
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.id, issueId))
|
|
|
|
|
.then((rows) => rows[0]);
|
|
|
|
|
expect(row).toEqual({
|
|
|
|
|
executionRunId: null,
|
|
|
|
|
executionAgentNameKey: null,
|
|
|
|
|
executionLockedAt: null,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not clear execution locks owned by live runs", async () => {
|
|
|
|
|
const { issueId, runId } = await seedIssueWithRun("running");
|
|
|
|
|
|
|
|
|
|
await expect(svc.clearExecutionRunIfTerminal(issueId)).resolves.toBe(false);
|
|
|
|
|
|
|
|
|
|
const row = await db
|
|
|
|
|
.select({
|
|
|
|
|
executionRunId: issues.executionRunId,
|
|
|
|
|
executionAgentNameKey: issues.executionAgentNameKey,
|
|
|
|
|
executionLockedAt: issues.executionLockedAt,
|
|
|
|
|
})
|
|
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.id, issueId))
|
|
|
|
|
.then((rows) => rows[0]);
|
|
|
|
|
expect(row?.executionRunId).toBe(runId);
|
|
|
|
|
expect(row?.executionAgentNameKey).toBe("codexcoder");
|
|
|
|
|
expect(row?.executionLockedAt).toBeInstanceOf(Date);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not update issues without an execution lock", async () => {
|
|
|
|
|
const { issueId } = await seedIssueWithRun(null);
|
|
|
|
|
|
|
|
|
|
await expect(svc.clearExecutionRunIfTerminal(issueId)).resolves.toBe(false);
|
|
|
|
|
|
|
|
|
|
const row = await db
|
|
|
|
|
.select({ executionRunId: issues.executionRunId, executionLockedAt: issues.executionLockedAt })
|
|
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.id, issueId))
|
|
|
|
|
.then((rows) => rows[0]);
|
|
|
|
|
expect(row).toEqual({ executionRunId: null, executionLockedAt: null });
|
|
|
|
|
});
|
|
|
|
|
});
|