From 66f08a2ea0392328e974a890219d622b67c0683d Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Wed, 15 Jul 2026 12:45:19 -0400 Subject: [PATCH 1/2] feat: coordinate code delivery lifecycle --- AGENTS.md | 51 +- CHANGELOG.md | 15 + DEVLOG.md | 41 ++ RELEASE.md | 53 +- docs/.vitepress/config.ts | 19 +- docs/engineering/work-management.md | 92 +++ package.json | 2 +- .../migration.sql | 67 +++ prisma/schema.prisma | 128 +++- scripts/deploy-production.sh | 67 +++ src/app/(app)/w/[slug]/dashboard/page.tsx | 7 + src/app/(app)/w/[slug]/issues/[id]/page.tsx | 2 + .../w/[slug]/settings/workspace/page.tsx | 22 + .../dashboard/delivery-work-tile.tsx | 94 +++ .../issue-detail/github-links-panel.tsx | 40 ++ .../issue-detail/work-coordination-panel.tsx | 340 +++++++++++ src/server/routers/_app.ts | 2 + src/server/routers/work-session.ts | 158 +++++ src/server/routers/workspace.ts | 2 + .../services/__tests__/work-session.test.ts | 179 ++++++ src/server/services/engagement-mode.ts | 11 +- src/server/services/github/resource-sync.ts | 12 +- src/server/services/mcp-exec.ts | 12 + src/server/services/mcp-policy.ts | 26 +- src/server/services/mcp.ts | 121 +++- src/server/services/work-session.ts | 546 ++++++++++++++++++ src/server/worker.ts | 23 + tests/e2e/dashboard-layout.spec.ts | 9 +- tests/unit/mcp-tool-profiles.test.ts | 5 +- 29 files changed, 2072 insertions(+), 74 deletions(-) create mode 100644 docs/engineering/work-management.md create mode 100644 prisma/migrations/20260715150000_work_coordination/migration.sql create mode 100755 scripts/deploy-production.sh create mode 100644 src/components/dashboard/delivery-work-tile.tsx create mode 100644 src/components/issue-detail/work-coordination-panel.tsx create mode 100644 src/server/routers/work-session.ts create mode 100644 src/server/services/__tests__/work-session.test.ts create mode 100644 src/server/services/work-session.ts diff --git a/AGENTS.md b/AGENTS.md index e3b2bd21..2bba1bac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,16 +15,16 @@ Vitest + Playwright. ## Where things live -| What | Where | -|-----------------------------|------------------------------------------| -| DB schema | `prisma/schema.prisma` | -| tRPC routers | `src/server/routers/` | -| Plugin runtime + MCP | `src/server/services/` | -| API route handlers | `src/app/api/` | -| App pages (RSC + client) | `src/app/(app)/` | -| Design tokens | `src/app/globals.css`, `tailwind.config.ts` | -| Sample plugin | `plugins/issue-triage/` | -| DevLog | `DEVLOG.md` — update at session end | +| What | Where | +| ------------------------ | ------------------------------------------- | +| DB schema | `prisma/schema.prisma` | +| tRPC routers | `src/server/routers/` | +| Plugin runtime + MCP | `src/server/services/` | +| API route handlers | `src/app/api/` | +| App pages (RSC + client) | `src/app/(app)/` | +| Design tokens | `src/app/globals.css`, `tailwind.config.ts` | +| Sample plugin | `plugins/issue-triage/` | +| DevLog | `DEVLOG.md` — update at session end | ## Primitives @@ -63,6 +63,7 @@ All tenant-scoped on `workspaceId`. `Workspace.autoDispatch` + `autoDispatchMode` drive automatic agent selection when an issue hits the queue unassigned. Modes: + - `MANUAL_ONLY` — dispatcher is a no-op; assignment is human-only. - `ROUND_ROBIN` — pick the least-recently-dispatched eligible agent. - `PRIORITY_MATCH` — prefer agents whose `capabilities` contain the @@ -70,10 +71,10 @@ selection when an issue hits the queue unassigned. Modes: - `CAPABILITY_MATCH` — intersect the issue's label names with agent capabilities; most matches wins; round-robin tie-break; zero-match falls through to round-robin rather than stalling dispatch. -`maxConcurrent` caps active assignments per agent (0 = unlimited). -`requireApprovalBeforeStart` gates push until a human approves. -`autoStartOnAssign` determines whether AGENT_ASSIGNED fires a webhook on -assignment (vs just filling the agent's queue). + `maxConcurrent` caps active assignments per agent (0 = unlimited). + `requireApprovalBeforeStart` gates push until a human approves. + `autoStartOnAssign` determines whether AGENT_ASSIGNED fires a webhook on + assignment (vs just filling the agent's queue). ## Configurability @@ -86,7 +87,7 @@ baked into handlers. Current knobs: `cycleLengthDays` (7), ## Granular ApiKey scopes -`ApiKey` has the usual coarse `PluginScope[]` ceiling *plus* three +`ApiKey` has the usual coarse `PluginScope[]` ceiling _plus_ three narrowing arrays: `projectIds`, `labelIds`, `initiativeIds`, plus an optional `linkedAgentId` pointing at the `Agent` row the key belongs to. Empty arrays = unrestricted within the declared scopes. Non-empty = @@ -149,3 +150,23 @@ hardcoded — those should stay small regardless of Appearance. 3. Append to `DEVLOG.md` 4. Commit; optional push. 5. Follow `RELEASE.md` for release tagging and deploy. + +## Code work coordination + +- GitHub `origin` is the code source of truth; Forge is the ownership and + delivery source of truth. Forge agents, Codex Desktop tasks, and contributors + follow the same workflow. +- One issue has one active work session, one isolated worktree/branch, and one + primary implementation PR. Check and claim the issue's Delivery session + before editing. Continue an existing session instead of creating competing + work. +- Branch names include the issue key (`codex/axi-123-description`). Never edit, + commit, or test changes in the deployment/main checkout. +- Heartbeat the work session during meaningful phases and after commits. Link + PRs through native GitHub `IMPLEMENTS` relations, never generic attachments. +- GitHub owns PR/review/check/merge truth. Forge records the delivery sequence: + Claimed → In progress → PR/review → Merged → Released → Deployed → Verified. +- Release/deploy/verification require explicit operator authority and are + serialized. See `docs/engineering/work-management.md` and `RELEASE.md`. +- Use timestamped Prisma migration directories to prevent parallel numbering + collisions. diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b2f02d..bb07ddf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ The dashboard's **What's New** rail reads this file at request time entries. Keep entries terse — one line per item under each version date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. +## [2026-07-15] — v0.20.0 · Coordinated code delivery + +### Added + +- **Issues now coordinate one active code work session across Forge, Codex Desktop, and contributors.** The Delivery card records the owner, repository, branch, base, isolated worktree, heartbeat, native implementation PR, and separate merge, release, deployment, and live-verification milestones. +- **Active delivery work remains visible after navigation.** A dashboard card shows current branches, owners, PR state, stale work, and the next delivery stage; workspace admins can tune the stale lease threshold. +- **Agents receive a code-work contract and MCP tools.** EXECUTE runs check and claim shared ownership before editing, heartbeat meaningful phases, and attach native GitHub implementation PRs instead of generic links. +- **Production deployment has a serialized exact-ref path.** A guarded script requires a clean dedicated clone, verifies the target belongs to `origin/main`, locks concurrent releases, stamps the build SHA, and smoke-tests the live sign-in route. + +### Changed + +- **GitHub remains authoritative for PR delivery facts.** Review, checks, mergeability, branch head, and merge state advance the Forge delivery session without creating a parallel PR status model. +- **Stale work is actionable instead of silently released.** Quiet sessions retain their ownership lease and create one shared action request until the owner resumes or an operator abandons the session. +- **Parallel contributors use timestamped Prisma migration names.** Repository, agent-context, release, docs-site, production compose, and Obsidian policies now share the same worktree and delivery rules. + ## [2026-07-15] — v0.19.1 · Refreshable GitHub sync health ### Added diff --git a/DEVLOG.md b/DEVLOG.md index 17746512..86c6f807 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,47 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-15 — Shared code-work ownership and guarded delivery + +Implemented the common delivery contract for Forge agents, Codex Desktop tasks, +and contributors. A first-class WorkSession provides one active coordination +lease per issue with repository, branch, base, worktree path, owner, heartbeat, +native PR, and explicit merged/released/deployed/verified milestones. Duplicate +work is rejected with the current owner and branch; stale work remains blocking +and raises one shared ActionRequest rather than being silently discarded. + +Native GitHub PR snapshots remain authoritative and advance attached sessions +through draft/review/ready/merged states using branch, aggregate checks, review +decision, and mergeability evidence. The issue rail adds a compact Delivery card +and richer GitHub evidence; the dashboard adds an active Delivery work card so +ownership and next steps survive navigation. Workspace settings now own the +stale lease threshold. Release/deploy/verification transitions require workspace +admin authority. + +Added `workSessions.*` MCP tools to the bounded runtime profile and injected the +coordination policy into every EXECUTE run: claim before editing, never work in +main/deployment checkouts, heartbeat after commits/phase changes, use native +IMPLEMENTS links, and stop at operator release/deploy gates. Added a timestamped +Prisma migration, service coverage for lease conflicts, PR-derived delivery, +milestone ordering, and stale action-request deduplication. + +Production compose now accepts `FORGE_SOURCE_PATH`; a dedicated clean deployment +clone was initialized at `/home/bailey/deploy/forge-prod`. The guarded deploy +script serializes with `flock`, resolves an exact tag/SHA, verifies main ancestry +and version/tag agreement, stamps the build, deploys both services, and smoke +tests the live sign-in route. Repository policy/docs and the canonical Obsidian +Delivery Workflows, Development Standards, Forge Release Workflow, and Hermes ↔ +Forge Integration notes were updated to the same model. + +Verification: lint and TypeScript passed; the focused coordination and MCP +profile suites passed **11/11**; the complete Vitest gate passed **1,297/1,299** +concurrently, with its one failed stale-dispatch case passing in isolation +**9/9** (the remaining test is skipped); the production docs + Next.js build +passed; and Playwright passed **37/39** concurrently, with the intentional +dashboard widget expectation corrected and both affected specs passing in a +serial follow-up **4/4**. The unrelated chat `/clear` timing case also passed in +that serial follow-up. + ## 2026-07-15 — Read-only GitHub sync health refresh Closed the post-release usability gap in workspace GitHub App settings. Added a diff --git a/RELEASE.md b/RELEASE.md index eab05888..590572d4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,25 +1,30 @@ # Forge Release Workflow > Canonical release policy for Forge. **GitHub Flow** — one long-lived branch -> (`main`), short-lived feature branches, ship continuously to the single +> (`main`), short-lived feature branches, and serialized releases to the single > self-hosted instance. ## Branch Model ``` -main ← the only long-lived branch; always deployable AND deployed; releases tagged here -feat/* ← short-lived feature branches from main → PR → squash-merge → main -fix/* ← bugfix branches from main -chore/* ← maintenance branches from main -docs/* ← documentation branches from main +main ← only long-lived branch; always deployable; releases tagged here +codex/-* ← Codex/Forge agent work +/-* ← human contributor work +fix/-* ← bugfix branches from main ``` **Rules:** + - `main` is always deployable. Every merge to `main` can ship. - Work lands via PR (review surface for humans + agents). Branch protection isn't available on this plan, so this is convention — keep it anyway. - Branches are short-lived — open the PR within a day or two of branching; delete merged branches immediately. +- One Forge issue owns one active work session, isolated worktree/branch, and + primary implementation PR. Claim it before editing; stale sessions must be + resumed or abandoned, never silently replaced. +- The checkout used for production is deployment-only. Development happens in + task-owned worktrees and never in the production or integration checkout. - No long-lived `dev`/`staging` branch. Reintroduce one only if/when a separate **staging deploy target** exists (then: `feat/* → PR → dev (staging) → PR → main (prod)`). @@ -28,28 +33,33 @@ docs/* ← documentation branches from main [Semantic Versioning 2.0.0](https://semver.org/). Pre-1.0 (`0.x`): MINOR is the "feature" bump, PATCH is fixes; breaking changes are allowed within `0.x`. -| Bump | When | -|------|------| -| **MAJOR** (`X.0.0`) | (post-1.0) breaking API/schema/UX changes | +| Bump | When | +| ------------------- | ----------------------------------------------------- | +| **MAJOR** (`X.0.0`) | (post-1.0) breaking API/schema/UX changes | | **MINOR** (`x.Y.0`) | New features, MCP tools, UI surfaces, additive schema | -| **PATCH** (`x.y.Z`) | Bug fixes, perf, security, docs | +| **PATCH** (`x.y.Z`) | Bug fixes, perf, security, docs | `package.json` `version` must match the tag you cut. ## Release Process For the same local gate used before direct releases, run: + ```bash pnpm ci:local ``` ### 1. Land the work -- Open a PR `feat/* → main`. CI (`.github/workflows/ci.yml`: lint · typecheck · unit · + +- Open a PR `/-* → main`. CI (`.github/workflows/ci.yml`: lint · typecheck · unit · Playwright e2e) must be green. **Squash-merge** with a clean message. - Update `CHANGELOG.md` under a heading `## [YYYY-MM-DD] — vX.Y.Z · Short title` — the bracket date drives the in-app "unseen" dot + ordering; the `— vX.Y.Z · Title` tail is the release name shown in What's New. Group items under `### Added/Changed/Fixed/Removed`. Bump `package.json` `version` to match the tag. (Draft with `pnpm changelog`, then curate.) +- Only the designated release owner changes the version, assembles final release + notes, and merges the release commit. Feature PRs must not race independent + version bumps or tags. > CI note: GitHub-hosted Actions require billing to be enabled. If Actions are > unavailable, gate the release on the equivalent local run @@ -57,33 +67,48 @@ pnpm ci:local > `docker/docker-compose.yml` stack) and record that in the release notes. ### 2. Tag the release + ```bash git checkout main && git pull origin main git tag vX.Y.Z && git push origin vX.Y.Z ``` + (Or trigger `.github/workflows/release.yml` — manual version bump + tag + GitHub Release — once Actions billing is restored.) ### 3. Deploy (Docker-Server) -Prod builds the working tree at `main`, stamped with the commit: + +Prod builds an exact tag/commit from a dedicated clean checkout, stamped with +the commit. The deployment lock permits one release at a time: + ```bash -cd /home/bailey/forge && git checkout main && git pull +flock /tmp/forge-production-deploy.lock +cd /home/bailey/deploy/forge-prod +git fetch origin main --tags +git checkout --detach vX.Y.Z +git status --porcelain # must be empty cd ~/docker/forge -GIT_SHA=$(git -C /home/bailey/forge rev-parse --short HEAD) \ +FORGE_SOURCE_PATH=/home/bailey/deploy/forge-prod \ +GIT_SHA=$(git -C /home/bailey/deploy/forge-prod rev-parse --short HEAD) \ BUILD_TIME=$(date -u +%FT%TZ) docker compose build forge forge-worker docker compose up -d # entrypoint runs `prisma migrate deploy` # run any one-time data backfill explicitly (prod image has no tsx — copy a .cjs + `node`) ``` ### 4. Smoke test + `forge.axiom-labs.dev` loads · sign-in works · core flows (issue create, agent dispatch, MCP health) · `system.buildInfo` reports the deployed SHA · `docker compose logs forge` clean. ### 5. Close the loop + Update `DEVLOG.md`; mark issues done; note state in the project log if it changed. +Record the exact release, deployed SHA, and live verification in the issue's +Delivery card. “Merged,” “released,” and “verified live” are separate facts. ## Hotfix + Same as a normal change — branch `fix/*` from `main`, PR, squash-merge, tag a PATCH, deploy. (No back-merge needed; `main` is the only long-lived branch.) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 9ecd975d..bdf811fb 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -31,11 +31,7 @@ export default defineConfig({ transformItems: (items) => items.map((item) => ({ ...item, url: `docs/${item.url.replace(/^\/+/, "")}` })), }, - ignoreDeadLinks: [ - /^https?:\/\/localhost/, - /^\/api\//, - /^\/w\//, - ], + ignoreDeadLinks: [/^https?:\/\/localhost/, /^\/api\//, /^\/w\//], head: [ // Fonts — match the dashboard exactly: Inter (sans) + JetBrains @@ -99,6 +95,11 @@ export default defineConfig({ { text: "Agents", link: "/agents/overview.html", activeMatch: "/agents/" }, { text: "Automation", link: "/automation/webhooks.html", activeMatch: "/automation/" }, { text: "Reference", link: "/reference/mcp.html", activeMatch: "/reference/" }, + { + text: "Engineering", + link: "/engineering/work-management.html", + activeMatch: "/engineering/", + }, ], sidebar: { @@ -199,6 +200,14 @@ export default defineConfig({ ], }, ], + "/engineering/": [ + { + text: "Engineering Operations", + items: [ + { text: "Work Management & Delivery", link: "/engineering/work-management.html" }, + ], + }, + ], }, outline: { level: [2, 3], label: "On this page" }, diff --git a/docs/engineering/work-management.md b/docs/engineering/work-management.md new file mode 100644 index 00000000..c1181c5d --- /dev/null +++ b/docs/engineering/work-management.md @@ -0,0 +1,92 @@ +# Work Management and Delivery Policy + +Forge and Codex Desktop are work surfaces. GitHub `origin` is the shared source +of truth for code; Forge is the source of truth for ownership and delivery +state. The same rules apply to humans, agents, and outside contributors. + +## One issue, one active delivery session + +Before changing code: + +1. Start from current `origin/main`. +2. Check the issue's **Delivery** card for active ownership. +3. Claim one work session with the repository, branch, base branch, and isolated + worktree path. +4. If a session already exists, continue it or explicitly abandon it. Do not + create a competing branch. + +Branch names include the issue key and owner namespace, for example +`codex/axi-123-short-description` or `bailey/axi-123-short-description`. +Never work directly in `main` or in the production checkout. + +## During implementation + +- Refresh the work-session heartbeat at meaningful phase changes and after + commits. Mechanical tool traces do not replace a heartbeat or semantic status. +- Push early and open a PR early when overlap is likely. +- Link implementation PRs with Forge's native GitHub relation and kind + `IMPLEMENTS`; never use a generic link attachment for a PR. +- GitHub owns PR state, reviews, checks, mergeability, and merge state. Forge + mirrors those facts into the delivery lifecycle. +- Resolve file overlap in the PR. Do not move uncommitted patches between + worktrees or share one worktree between tasks. + +## Delivery lifecycle + +```text +Claimed → In progress → PR open → In review → Ready to merge → Merged + → Released → Deployed → Verified +``` + +`Verified` is the terminal success state. `Abandoned` explicitly releases the +ownership lease. A stale session remains a lease and raises a shared action +request; it must be resumed or abandoned before replacement work starts. + +Release, deploy, and verification transitions require workspace admin authority. +Feature work may run in parallel, but merges and production delivery are +serialized. + +## Pull requests + +- Every code change lands through a PR, including agent work and hotfixes. +- CI must be green on the current head. +- Address unresolved review threads before merge. +- Refresh from `origin/main` when the base changed materially. +- Squash-merge, delete the remote branch, then remove the local worktree. +- The issue is not done merely because an agent stopped or a PR opened. + +## Collision-sensitive changes + +Take extra care around Prisma schema/migrations, package versions, changelog, +shared routers, foundational UI, and deployment configuration. + +- Prisma migrations use timestamped names (`YYYYMMDDHHMMSS_description`) so + concurrent contributors cannot allocate the same sequence number. +- Feature PRs do not independently cut competing releases. A designated release + owner assembles release notes, bumps the version, tags the exact merged SHA, + and deploys it. +- Never silently resolve a schema or migration conflict by dropping another + contributor's migration. + +## Production + +Production builds an exact tag or commit from a dedicated clean checkout. The +development checkout is never a deployment source. A deploy is accepted only +when: + +- the target commit is on `origin/main`; +- the checkout is clean; +- CI/local release gates passed; +- the deployment lock is held; +- migrations complete; +- smoke checks pass; and +- the running build reports the expected SHA. + +Record release, deployment, and live verification separately in Forge so an +operator can distinguish “merged” from “actually available.” + +## Cleanup + +After merge or abandonment, remove the worktree and prune the branch. Review +stale sessions rather than deleting them automatically: an old worktree may +contain unpushed work. Preserve or recover that work before cleanup. diff --git a/package.json b/package.json index a01d12b5..5933fb68 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forge", - "version": "0.19.1", + "version": "0.20.0", "private": true, "description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.", "license": "MIT", diff --git a/prisma/migrations/20260715150000_work_coordination/migration.sql b/prisma/migrations/20260715150000_work_coordination/migration.sql new file mode 100644 index 00000000..ad601f6f --- /dev/null +++ b/prisma/migrations/20260715150000_work_coordination/migration.sql @@ -0,0 +1,67 @@ +-- Timestamped migration names are collision-safe for parallel contributors. +CREATE TYPE "WorkSessionStatus" AS ENUM ( + 'CLAIMED', 'IN_PROGRESS', 'PR_OPEN', 'IN_REVIEW', 'READY_TO_MERGE', + 'MERGED', 'RELEASED', 'DEPLOYED', 'VERIFIED', 'STALE', 'ABANDONED' +); + +CREATE TYPE "WorkSessionSource" AS ENUM ( + 'FORGE_AGENT', 'CODEX_DESKTOP', 'CONTRIBUTOR', 'MANUAL' +); + +ALTER TABLE "Workspace" + ADD COLUMN "workSessionStaleMinutes" INTEGER NOT NULL DEFAULT 120; + +CREATE TABLE "WorkSession" ( + "id" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "issueId" TEXT NOT NULL, + "ownerUserId" TEXT, + "ownerAgentId" TEXT, + "source" "WorkSessionSource" NOT NULL, + "status" "WorkSessionStatus" NOT NULL DEFAULT 'CLAIMED', + "repoFullName" TEXT NOT NULL, + "branch" TEXT NOT NULL, + "baseBranch" TEXT NOT NULL DEFAULT 'main', + "worktreePath" TEXT, + "headSha" TEXT, + "pullRequestId" TEXT, + "releasedVersion" TEXT, + "deployedSha" TEXT, + "lastHeartbeatAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "staleAt" TIMESTAMP(3), + "mergedAt" TIMESTAMP(3), + "releasedAt" TIMESTAMP(3), + "deployedAt" TIMESTAMP(3), + "verifiedAt" TIMESTAMP(3), + "endedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WorkSession_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "WorkSession_workspaceId_repoFullName_branch_key" + ON "WorkSession"("workspaceId", "repoFullName", "branch"); +CREATE INDEX "WorkSession_workspaceId_status_lastHeartbeatAt_idx" + ON "WorkSession"("workspaceId", "status", "lastHeartbeatAt"); +CREATE INDEX "WorkSession_issueId_createdAt_idx" ON "WorkSession"("issueId", "createdAt"); +CREATE INDEX "WorkSession_ownerUserId_status_idx" ON "WorkSession"("ownerUserId", "status"); +CREATE INDEX "WorkSession_ownerAgentId_status_idx" ON "WorkSession"("ownerAgentId", "status"); +CREATE INDEX "WorkSession_pullRequestId_idx" ON "WorkSession"("pullRequestId"); + +-- A single active coordination lease per issue. Historical terminal sessions +-- remain available without preventing a later follow-up branch. +CREATE UNIQUE INDEX "WorkSession_one_active_per_issue" + ON "WorkSession"("issueId") + WHERE "status" NOT IN ('VERIFIED', 'ABANDONED'); + +ALTER TABLE "WorkSession" ADD CONSTRAINT "WorkSession_workspaceId_fkey" + FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WorkSession" ADD CONSTRAINT "WorkSession_issueId_fkey" + FOREIGN KEY ("issueId") REFERENCES "Issue"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WorkSession" ADD CONSTRAINT "WorkSession_ownerUserId_fkey" + FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "WorkSession" ADD CONSTRAINT "WorkSession_ownerAgentId_fkey" + FOREIGN KEY ("ownerAgentId") REFERENCES "Agent"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "WorkSession" ADD CONSTRAINT "WorkSession_pullRequestId_fkey" + FOREIGN KEY ("pullRequestId") REFERENCES "ExternalResource"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4489e19d..f01f5129 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -211,6 +211,29 @@ enum CompletionAutomation { AUTO_WHEN_SAFE } +/// Canonical lifecycle for an isolated code worktree/branch attached to an issue. +/// GitHub remains authoritative for PR/check/review facts; Forge owns coordination. +enum WorkSessionStatus { + CLAIMED + IN_PROGRESS + PR_OPEN + IN_REVIEW + READY_TO_MERGE + MERGED + RELEASED + DEPLOYED + VERIFIED + STALE + ABANDONED +} + +enum WorkSessionSource { + FORGE_AGENT + CODEX_DESKTOP + CONTRIBUTOR + MANUAL +} + enum ChatRole { USER AGENT @@ -606,6 +629,7 @@ model User { requestedAgentProfiles AgentProfile[] @relation("AgentProfileRequester") connections Connection[] externalResourceLinks ExternalResourceLink[] @relation("ExternalResourceLinkCreator") + ownedWorkSessions WorkSession[] @relation("WorkSessionOwnerUser") authoredIssues Issue[] @relation("IssueAuthor") assignedIssues IssueAssignee[] @@ -908,6 +932,9 @@ model Workspace { githubClosedReprobeMinutes Int @default(1440) /// Minimum interval between operator/agent manual refresh requests per PR. githubManualCooldownSeconds Int @default(30) + /// An active code work session with no heartbeat beyond this threshold is + /// surfaced as stale. 0 disables automatic stale marking. + workSessionStaleMinutes Int @default(120) /// Issues with `updatedAt` older than this threshold (in days) are /// considered "stalled" by the dashboard suggestions bucket. Settings- /// driven so admins can tune the workspace's idle definition without @@ -975,6 +1002,7 @@ model Workspace { externalResources ExternalResource[] externalResourceLinks ExternalResourceLink[] externalWebhookEvents ExternalWebhookEvent[] + workSessions WorkSession[] pins Pin[] recentItems RecentItem[] notes Note[] @@ -1221,6 +1249,7 @@ model Issue { goals Goal[] actionRequests ActionRequest[] externalLinks ExternalResourceLink[] + workSessions WorkSession[] @@unique([workspaceId, number]) @@index([workspaceId, statusId]) @@ -2036,17 +2065,17 @@ model RuntimeRepo { /// The same row can own native issue/PR webhook sync; instance env credentials /// remain a legacy fallback — see `src/server/services/github/webhook.ts`. model GithubApp { - id String @id @default(cuid()) - workspaceId String + id String @id @default(cuid()) + workspaceId String /// Operator-facing label (e.g. "Axiom Bot"). - name String + name String /// Numeric GitHub App ID — the JWT issuer used to mint installation tokens. - appId String + appId String /// Installation ID (the app installed on an org/account). Null until the app /// is installed; tokens are scoped to whatever repos the installation grants. - installationId String? + installationId String? /// AES-256-GCM ciphertext of the app's PEM private key. Write-only. - privateKeyEnc String + privateKeyEnc String /// HMAC secret for this app's native Forge webhook. Encrypted with /// AUTH_SECRET and never returned to clients. Keeping webhook auth beside /// the app removes the old requirement for a second, instance-env app. @@ -2059,17 +2088,17 @@ model GithubApp { webhookLastError String? /// App slug (e.g. `forge-bot`) for deep-linking to GitHub's install/settings /// pages. From the manifest conversion, or the first successful test. - slug String? + slug String? /// OAuth client id from the manifest conversion (informational; nullable). - clientId String? + clientId String? /// Created via the manifest flow (vs. manual paste). UI hint only. - createdViaManifest Boolean @default(false) + createdViaManifest Boolean @default(false) /// Last successful token mint, for at-a-glance health in the UI. - lastMintedAt DateTime? + lastMintedAt DateTime? /// Last mint/verify error message (cleared on success). UI surfaces it. - lastError String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + lastError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) runtimes Runtime[] @@ -2230,6 +2259,7 @@ model ExternalResource { workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) connectionMapping ConnectionMapping? @relation(fields: [connectionMappingId], references: [id], onDelete: SetNull) links ExternalResourceLink[] + workSessions WorkSession[] @@unique([workspaceId, provider, repoFullName, resourceType, number]) @@index([workspaceId, provider, repoFullName]) @@ -2263,27 +2293,72 @@ model ExternalResourceLink { @@index([createdById]) } +/// A shared coordination lease for implementation work. It prevents a Forge +/// agent, Codex Desktop task, or contributor from silently starting a second +/// branch for the same issue. Terminal rows remain as the issue's delivery +/// history; a partial unique index in the migration permits one active row. +model WorkSession { + id String @id @default(cuid()) + workspaceId String + issueId String + ownerUserId String? + ownerAgentId String? + source WorkSessionSource + status WorkSessionStatus @default(CLAIMED) + repoFullName String + branch String + baseBranch String @default("main") + /// Local path is diagnostic only; it is never used as an execution target. + worktreePath String? + headSha String? + pullRequestId String? + releasedVersion String? + deployedSha String? + lastHeartbeatAt DateTime @default(now()) + staleAt DateTime? + mergedAt DateTime? + releasedAt DateTime? + deployedAt DateTime? + verifiedAt DateTime? + endedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) + ownerUser User? @relation("WorkSessionOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull) + ownerAgent Agent? @relation("WorkSessionOwnerAgent", fields: [ownerAgentId], references: [id], onDelete: SetNull) + pullRequest ExternalResource? @relation(fields: [pullRequestId], references: [id], onDelete: SetNull) + + @@unique([workspaceId, repoFullName, branch]) + @@index([workspaceId, status, lastHeartbeatAt]) + @@index([issueId, createdAt]) + @@index([ownerUserId, status]) + @@index([ownerAgentId, status]) + @@index([pullRequestId]) +} + /// Inbound external webhook idempotency and diagnostics. For GitHub this /// stores `X-GitHub-Delivery` plus event/action so redeliveries can be /// acknowledged without duplicating Forge side effects. model ExternalWebhookEvent { - id String @id @default(cuid()) - workspaceId String? - provider String - deliveryId String - event String - action String? - repoFullName String? - externalResourceId String? - status String - error String? @db.Text - receivedAt DateTime @default(now()) + id String @id @default(cuid()) + workspaceId String? + provider String + deliveryId String + event String + action String? + repoFullName String? + externalResourceId String? + status String + error String? @db.Text + receivedAt DateTime @default(now()) /// Start of the current processing lease. Failed or abandoned deliveries /// can be atomically reclaimed without racing an in-flight request. processingStartedAt DateTime? /// Number of processing claims, including the initial delivery. - attemptCount Int @default(1) - processedAt DateTime? + attemptCount Int @default(1) + processedAt DateTime? workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@ -2465,6 +2540,7 @@ model Agent { assignedActionRequests ActionRequest[] @relation("ActionRequestAssigneeAgent") actorAuditLogs AuditLog[] @relation("AuditLogActorAgent") actorActivityEvents ActivityEvent[] @relation("ActivityEventActorAgent") + ownedWorkSessions WorkSession[] @relation("WorkSessionOwnerAgent") @@unique([workspaceId, profileKey]) @@index([workspaceId, status]) diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100755 index 00000000..6200ff55 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Serialize and deploy an exact main commit/tag from a dedicated clean clone. +# Usage: scripts/deploy-production.sh v0.20.0 + +REF=${1:?"usage: scripts/deploy-production.sh "} +SOURCE_PATH=${FORGE_SOURCE_PATH:-/home/bailey/deploy/forge-prod} +COMPOSE_PATH=${FORGE_COMPOSE_PATH:-/home/bailey/docker/forge} +LOCK_PATH=${FORGE_DEPLOY_LOCK:-/tmp/forge-production-deploy.lock} +PUBLIC_URL=${FORGE_PUBLIC_URL:-https://forge.axiom-labs.dev} + +exec 9>"$LOCK_PATH" +if ! flock -n 9; then + echo "Another Forge production deployment holds $LOCK_PATH." >&2 + exit 1 +fi + +if [[ ! -d "$SOURCE_PATH/.git" ]]; then + echo "Dedicated deployment clone not found at $SOURCE_PATH." >&2 + echo "Create it with: git clone https://github.com/Codename-11/Forge.git $SOURCE_PATH" >&2 + exit 1 +fi + +if [[ -n "$(git -C "$SOURCE_PATH" status --porcelain)" ]]; then + echo "Deployment checkout is dirty; refusing to overwrite it." >&2 + exit 1 +fi + +git -C "$SOURCE_PATH" fetch --prune origin main --tags +TARGET=$(git -C "$SOURCE_PATH" rev-parse "${REF}^{commit}") +if ! git -C "$SOURCE_PATH" merge-base --is-ancestor "$TARGET" origin/main; then + echo "$REF ($TARGET) is not contained in origin/main." >&2 + exit 1 +fi + +git -C "$SOURCE_PATH" checkout --detach "$TARGET" + +if [[ "$REF" == v* ]]; then + PACKAGE_VERSION=$(node -p "require('$SOURCE_PATH/package.json').version") + if [[ "v$PACKAGE_VERSION" != "$REF" ]]; then + echo "Tag $REF does not match package version v$PACKAGE_VERSION." >&2 + exit 1 + fi +fi + +SHORT_SHA=$(git -C "$SOURCE_PATH" rev-parse --short HEAD) +BUILD_TIME=$(date -u +%FT%TZ) + +cd "$COMPOSE_PATH" +FORGE_SOURCE_PATH="$SOURCE_PATH" GIT_SHA="$SHORT_SHA" BUILD_TIME="$BUILD_TIME" \ + docker compose build forge forge-worker +FORGE_SOURCE_PATH="$SOURCE_PATH" GIT_SHA="$SHORT_SHA" BUILD_TIME="$BUILD_TIME" \ + docker compose up -d + +for _ in $(seq 1 30); do + if curl --fail --silent --show-error "$PUBLIC_URL/signin" >/dev/null; then + echo "Forge $REF deployed at $SHORT_SHA ($BUILD_TIME)." + exit 0 + fi + sleep 2 +done + +echo "Deployment started but $PUBLIC_URL/signin did not become healthy within 60 seconds." >&2 +docker compose ps >&2 +docker compose logs --tail=100 forge forge-worker >&2 +exit 1 diff --git a/src/app/(app)/w/[slug]/dashboard/page.tsx b/src/app/(app)/w/[slug]/dashboard/page.tsx index 2af026dd..cbc62c7e 100644 --- a/src/app/(app)/w/[slug]/dashboard/page.tsx +++ b/src/app/(app)/w/[slug]/dashboard/page.tsx @@ -31,6 +31,7 @@ import { WhatsNewTile } from "@/components/dashboard/whats-new-tile"; import { AgentActivityTile } from "@/components/dashboard/agent-activity-tile"; import { NeedsYouTile } from "@/components/dashboard/needs-you-tile"; import { PulseTile } from "@/components/dashboard/pulse-tile"; +import { DeliveryWorkTile } from "@/components/dashboard/delivery-work-tile"; import { IssueCard, type DashboardWorkCard } from "@/components/dashboard/issue-card"; import { AgentAttentionPanel } from "@/components/agent-attention-panel"; import { WorkspaceActivityTimeline } from "@/components/workspace-activity-timeline"; @@ -185,6 +186,12 @@ export default function DashboardPage() { defaultWidth: "full", node: , }, + { + id: "delivery-work", + title: "Delivery work", + defaultWidth: "full", + node: , + }, { id: "agent-activity", title: "Agent activity", diff --git a/src/app/(app)/w/[slug]/issues/[id]/page.tsx b/src/app/(app)/w/[slug]/issues/[id]/page.tsx index e92eb1ca..c1ccd268 100644 --- a/src/app/(app)/w/[slug]/issues/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/issues/[id]/page.tsx @@ -34,6 +34,7 @@ import { useWorkspace } from "@/hooks/use-workspace"; import { PinButton } from "@/components/pins/pin-button"; import { WatchButton } from "@/components/watch-button"; import { IssueDetailTopbar } from "@/components/issue-detail/issue-topbar"; +import { WorkCoordinationPanel } from "@/components/issue-detail/work-coordination-panel"; import { IssueMain } from "@/components/issue-detail/issue-main"; import { IssueRail } from "@/components/issue-detail/issue-rail"; import { AgentRunStrip } from "@/components/issue-detail/agent-run-strip"; @@ -618,6 +619,7 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri activityCount={recentEvents?.length ?? 0} header={
+ 0 ? runTokenBudget : null, @@ -313,6 +321,7 @@ export default function WorkspaceSettingsPage() { agentProgressUpdateMinutes, agentRunQuietMinutes, reviewStartTimeoutMinutes, + workSessionStaleMinutes, runTokenBudget, runCostBudgetUsd, runMaxMinutes, @@ -598,6 +607,19 @@ export default function WorkspaceSettingsPage() { disabled={!canEdit} /> + + setWorkSessionStaleMinutes(Number(e.target.value) || 0)} + disabled={!canEdit} + /> +
= { + CLAIMED: "claimed", + IN_PROGRESS: "working", + PR_OPEN: "PR open", + IN_REVIEW: "review", + READY_TO_MERGE: "ready", + MERGED: "merged", + RELEASED: "released", + DEPLOYED: "deployed", + STALE: "stale", +}; + +export function DeliveryWorkTile() { + const { data, isLoading } = trpc.workSession.active.useQuery(undefined, { + staleTime: 15_000, + refetchOnWindowFocus: true, + }); + if (isLoading) + return
; + if (!data?.length) return null; + + return ( +
+
+ +

Delivery work

+ + {data.length} + +
+
+ {data.slice(0, 6).map((session) => { + const owner = + session.ownerAgent?.name ?? + session.ownerUser?.name ?? + session.ownerUser?.email ?? + "Unassigned"; + return ( + + {session.status === "STALE" ? ( + + ) : session.pullRequest ? ( + + ) : ( + + )} +
+
+ + {session.issue.workspace.key}-{session.issue.number} + + {session.issue.title} +
+
+ {owner} + · + {session.branch} + · + {relativeTime(session.lastHeartbeatAt)} +
+
+ + {LABEL[session.status] ?? session.status.toLowerCase()} + + + ); + })} +
+
+ ); +} diff --git a/src/components/issue-detail/github-links-panel.tsx b/src/components/issue-detail/github-links-panel.tsx index 6100cd44..f929b0a2 100644 --- a/src/components/issue-detail/github-links-panel.tsx +++ b/src/components/issue-detail/github-links-panel.tsx @@ -21,6 +21,12 @@ function stateLabel(state: string): string { return state || "unknown"; } +function metadataRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + export function GitHubLinksPanel({ issueId }: { issueId: string }) { const utils = trpc.useUtils(); const [modalOpen, setModalOpen] = useState(false); @@ -67,6 +73,24 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) { {links.map((link) => { const resource = link.externalResource; const isPr = resource.resourceType === "PULL_REQUEST"; + const metadata = metadataRecord(resource.metadata); + const head = metadataRecord(metadata.head); + const checks = metadataRecord(metadata.checks); + const review = metadataRecord(metadata.review); + const reviewDecision = + typeof metadata.reviewDecision === "string" + ? metadata.reviewDecision + : typeof review.decision === "string" + ? review.decision + : null; + const checksLabel = + checks.partial === true + ? "checks partial" + : typeof checks.conclusion === "string" + ? `checks ${checks.conclusion}` + : typeof checks.status === "string" + ? `checks ${checks.status}` + : null; return (
@@ -95,6 +119,22 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) { synced {relativeTime(resource.lastSyncedAt)} )}
+ {isPr && ( +
+ {typeof head.ref === "string" && ( + + {head.ref} + + )} + {reviewDecision && ( + {reviewDecision.toLowerCase().replaceAll("_", " ")} + )} + {checksLabel && {checksLabel}} + {typeof metadata.mergeableState === "string" && ( + merge {metadata.mergeableState} + )} +
+ )} {resource.syncLastError && (
= { + CLAIMED: { label: "Claimed", tone: "text-muted-foreground" }, + IN_PROGRESS: { label: "In progress", tone: "text-ember" }, + PR_OPEN: { label: "PR open", tone: "text-ember" }, + IN_REVIEW: { label: "In review", tone: "text-warning" }, + READY_TO_MERGE: { label: "Ready to merge", tone: "text-success" }, + MERGED: { label: "Merged", tone: "text-success" }, + RELEASED: { label: "Released", tone: "text-success" }, + DEPLOYED: { label: "Deployed", tone: "text-success" }, + VERIFIED: { label: "Verified", tone: "text-success" }, + STALE: { label: "Stale", tone: "text-warning" }, + ABANDONED: { label: "Abandoned", tone: "text-muted-foreground" }, +}; + +export function WorkCoordinationPanel({ + issueId, + issueKey, +}: { + issueId: string; + issueKey: string; +}) { + const workspace = useWorkspace(); + const canRelease = workspace.role === "OWNER" || workspace.role === "ADMIN"; + const utils = trpc.useUtils(); + const [creating, setCreating] = useState(false); + const [repo, setRepo] = useState(""); + const [branch, setBranch] = useState(`codex/${issueKey.toLowerCase()}-work`); + const [baseBranch, setBaseBranch] = useState("main"); + const [worktreePath, setWorktreePath] = useState(""); + const [releaseVersion, setReleaseVersion] = useState(""); + + const { data: sessions, isLoading } = trpc.workSession.listForIssue.useQuery( + { issueId }, + { staleTime: 10_000, refetchOnWindowFocus: true }, + ); + const { data: mappings } = trpc.github.listMappings.useQuery({ includePaused: false }); + const { data: build } = trpc.system.buildInfo.useQuery(); + useEffect(() => { + if (!repo && mappings?.[0]?.repoFullName) setRepo(mappings[0].repoFullName); + }, [mappings, repo]); + + const active = useMemo( + () => sessions?.find((session) => !TERMINAL.has(session.status)) ?? null, + [sessions], + ); + const latest = active ?? sessions?.[0] ?? null; + const invalidate = () => { + void utils.workSession.listForIssue.invalidate({ issueId }); + void utils.issue.byId.invalidate({ id: issueId }); + }; + const claim = trpc.workSession.claim.useMutation({ + onSuccess: () => { + toast.success("Isolated work session claimed."); + setCreating(false); + invalidate(); + }, + onError: (error) => toast.error(error.message), + }); + const heartbeat = trpc.workSession.heartbeat.useMutation({ + onSuccess: () => { + toast.success("Work session refreshed."); + invalidate(); + }, + onError: (error) => toast.error(error.message), + }); + const advance = trpc.workSession.advance.useMutation({ + onSuccess: () => invalidate(), + onError: (error) => toast.error(error.message), + }); + + const owner = latest?.ownerAgent + ? `${latest.ownerAgent.name} · @${latest.ownerAgent.profileKey}` + : (latest?.ownerUser?.name ?? latest?.ownerUser?.email ?? "Unassigned"); + const status = latest ? (STATUS_COPY[latest.status] ?? STATUS_COPY.CLAIMED) : null; + + return ( +
+
+ + + Delivery + + {status && ( + + {status.label} + + )} +
+ + {isLoading ? ( +
Checking ownership…
+ ) : latest ? ( +
+
+
+ {latest.status === "STALE" ? ( + + ) : latest.status === "VERIFIED" ? ( + + ) : ( + + )} +
+
{owner}
+
+ {latest.repoFullName}:{latest.branch} +
+
+ {latest.source.toLowerCase().replaceAll("_", " ")} · base {latest.baseBranch} · + seen {relativeTime(latest.lastHeartbeatAt)} +
+ {latest.worktreePath && ( +
+ {latest.worktreePath} +
+ )} +
+
+
+ + {latest.pullRequest ? ( + + + + {latest.pullRequest.repoFullName}#{latest.pullRequest.number} + + {latest.pullRequest.state} + + ) : !TERMINAL.has(latest.status) ? ( +
+ Open a PR from this branch and link it as{" "} + implements. Forge will match it automatically. +
+ ) : null} + + {!TERMINAL.has(latest.status) && ( +
+ {(latest.status === "STALE" || latest.status === "CLAIMED") && ( + + )} + {latest.status === "MERGED" && canRelease && ( +
+ setReleaseVersion(e.target.value)} + placeholder="v0.20.0" + className="h-8 min-w-0" + /> + +
+ )} + {latest.status === "RELEASED" && canRelease && ( + + )} + {latest.status === "DEPLOYED" && canRelease && ( + + )} + + {!canRelease && ["MERGED", "RELEASED", "DEPLOYED"].includes(latest.status) && ( + + Waiting for workspace admin approval + + )} +
+ )} +
+ ) : creating ? ( +
{ + event.preventDefault(); + claim.mutate({ + issueId, + repoFullName: repo, + branch, + baseBranch, + worktreePath: worktreePath || null, + source: "CODEX_DESKTOP", + }); + }} + > + + +
+ + +
+
+ + +
+
+ ) : ( +
+

+ Claim an isolated branch before editing so Forge agents, Desktop tasks, and contributors + share one owner. +

+ +
+ )} +
+ ); +} diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index 80edeef7..8c90955f 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -56,6 +56,7 @@ import { runtimeRouter } from "./runtime"; import { githubAppRouter } from "./github-app"; import { timeEntryRouter } from "./timeEntry"; import { userRouter } from "./user"; +import { workSessionRouter } from "./work-session"; export const appRouter = router({ access: accessRouter, @@ -117,6 +118,7 @@ export const appRouter = router({ user: userRouter, view: viewRouter, workspace: workspaceRouter, + workSession: workSessionRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/server/routers/work-session.ts b/src/server/routers/work-session.ts new file mode 100644 index 00000000..9421df8d --- /dev/null +++ b/src/server/routers/work-session.ts @@ -0,0 +1,158 @@ +import { WorkSessionSource } from "@prisma/client"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { router, workspaceProcedure } from "@/server/trpc"; +import { + advanceWorkSession, + attachPullRequest, + claimWorkSession, + listIssueWorkSessions, + touchWorkSession, +} from "@/server/services/work-session"; + +const repoSchema = z + .string() + .min(3) + .max(200) + .regex(/^[\w.-]+\/[\w.-]+$/, "Repository must be owner/name."); +const branchSchema = z + .string() + .min(1) + .max(240) + .regex(/^[A-Za-z0-9._/-]+$/, "Branch contains unsupported characters."); + +async function assertSessionManager( + ctx: { + db: Parameters[0]; + workspaceId: string; + membership: { role: string }; + session: { user: { id: string } }; + }, + sessionId: string, + releaseAuthority = false, +) { + const isAdmin = ctx.membership.role === "OWNER" || ctx.membership.role === "ADMIN"; + if (releaseAuthority && !isAdmin) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Workspace admin approval is required for release and deployment milestones.", + }); + } + const session = await ctx.db.workSession.findFirst({ + where: { id: sessionId, workspaceId: ctx.workspaceId }, + select: { ownerUserId: true }, + }); + if (!session) throw new TRPCError({ code: "NOT_FOUND", message: "Work session not found." }); + if (!isAdmin && session.ownerUserId !== ctx.session.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the work-session owner or a workspace admin can manage this session.", + }); + } +} + +export const workSessionRouter = router({ + active: workspaceProcedure.query(({ ctx }) => + ctx.db.workSession.findMany({ + where: { + workspaceId: ctx.workspaceId, + status: { notIn: ["VERIFIED", "ABANDONED"] }, + }, + orderBy: [{ staleAt: "desc" }, { updatedAt: "desc" }], + take: 20, + include: { + issue: { + select: { + id: true, + number: true, + title: true, + status: { select: { name: true, category: true, color: true } }, + workspace: { select: { key: true, slug: true } }, + }, + }, + ownerUser: { select: { id: true, name: true, email: true, image: true } }, + ownerAgent: { select: { id: true, name: true, profileKey: true, avatar: true } }, + pullRequest: { + select: { number: true, repoFullName: true, url: true, state: true, metadata: true }, + }, + }, + }), + ), + + listForIssue: workspaceProcedure + .input(z.object({ issueId: z.string().cuid() })) + .query(({ ctx, input }) => listIssueWorkSessions(ctx.db, ctx.workspaceId, input.issueId)), + + claim: workspaceProcedure + .input( + z.object({ + issueId: z.string().cuid(), + repoFullName: repoSchema, + branch: branchSchema, + baseBranch: branchSchema.default("main"), + worktreePath: z.string().max(1_000).nullable().optional(), + source: z.nativeEnum(WorkSessionSource).default(WorkSessionSource.CODEX_DESKTOP), + }), + ) + .mutation(({ ctx, input }) => + claimWorkSession(ctx.db, { + workspaceId: ctx.workspaceId, + ...input, + actor: { userId: ctx.session.user.id, agentId: ctx.apiKey?.linkedAgentId ?? null }, + }), + ), + + heartbeat: workspaceProcedure + .input( + z.object({ + sessionId: z.string().cuid(), + headSha: z + .string() + .regex(/^[a-f0-9]{7,64}$/i) + .nullable() + .optional(), + worktreePath: z.string().max(1_000).nullable().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + await assertSessionManager(ctx, input.sessionId); + return touchWorkSession(ctx.db, { + workspaceId: ctx.workspaceId, + ...input, + actor: { userId: ctx.session.user.id, agentId: ctx.apiKey?.linkedAgentId ?? null }, + }); + }), + + attachPullRequest: workspaceProcedure + .input(z.object({ sessionId: z.string().cuid(), externalResourceId: z.string().cuid() })) + .mutation(async ({ ctx, input }) => { + await assertSessionManager(ctx, input.sessionId); + return attachPullRequest(ctx.db, { + workspaceId: ctx.workspaceId, + ...input, + actor: { userId: ctx.session.user.id, agentId: ctx.apiKey?.linkedAgentId ?? null }, + }); + }), + + advance: workspaceProcedure + .input( + z.object({ + sessionId: z.string().cuid(), + status: z.enum(["RELEASED", "DEPLOYED", "VERIFIED", "ABANDONED"]), + releasedVersion: z.string().max(80).nullable().optional(), + deployedSha: z + .string() + .regex(/^[a-f0-9]{7,64}$/i) + .nullable() + .optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + await assertSessionManager(ctx, input.sessionId, input.status !== "ABANDONED"); + return advanceWorkSession(ctx.db, { + workspaceId: ctx.workspaceId, + ...input, + actor: { userId: ctx.session.user.id, agentId: ctx.apiKey?.linkedAgentId ?? null }, + }); + }), +}); diff --git a/src/server/routers/workspace.ts b/src/server/routers/workspace.ts index b09b6671..30f46b02 100644 --- a/src/server/routers/workspace.ts +++ b/src/server/routers/workspace.ts @@ -148,6 +148,7 @@ export const workspaceRouter = router({ githubSweepBudgetSeconds: true, githubClosedReprobeMinutes: true, githubManualCooldownSeconds: true, + workSessionStaleMinutes: true, stalledThresholdDays: true, agentHeartbeatWarnMinutes: true, agentHeartbeatCriticalMinutes: true, @@ -328,6 +329,7 @@ export const workspaceRouter = router({ githubSweepBudgetSeconds: z.number().int().min(5).max(300).optional(), githubClosedReprobeMinutes: z.number().int().min(60).max(43200).optional(), githubManualCooldownSeconds: z.number().int().min(1).max(3600).optional(), + workSessionStaleMinutes: z.number().int().min(0).max(43200).optional(), assignmentEngagementMode: z.nativeEnum(EngagementMode).optional(), mentionEngagementPolicy: z.nativeEnum(MentionEngagementPolicy).optional(), mentionDefaultMode: z.nativeEnum(EngagementMode).optional(), diff --git a/src/server/services/__tests__/work-session.test.ts b/src/server/services/__tests__/work-session.test.ts new file mode 100644 index 00000000..8723bb48 --- /dev/null +++ b/src/server/services/__tests__/work-session.test.ts @@ -0,0 +1,179 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { WorkSessionSource } from "@prisma/client"; +import { + advanceWorkSession, + attachPullRequest, + claimWorkSession, + sweepStaleWorkSessions, +} from "@/server/services/work-session"; +import { + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => disconnectPrisma()); + +async function setup() { + const fixture = await createWorkspaceFixture({ keyPrefix: "WS" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" }); + return { fixture, prisma, issue }; +} + +describe("work session coordination", () => { + it("returns the owned lease idempotently and rejects competing work", async () => { + const { fixture, prisma, issue } = await setup(); + const first = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/ws-1-feature", + source: WorkSessionSource.CODEX_DESKTOP, + actor: { userId: fixture.user.id }, + }); + const same = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/ws-1-feature", + source: WorkSessionSource.CODEX_DESKTOP, + actor: { userId: fixture.user.id }, + }); + expect(same.id).toBe(first.id); + + await expect( + claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "contributor/competing", + source: WorkSessionSource.CONTRIBUTOR, + actor: { userId: fixture.secondUser.id }, + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it("derives merge state from a native implementation PR and gates delivery milestones", async () => { + const { fixture, prisma, issue } = await setup(); + const session = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/ws-2-feature", + source: WorkSessionSource.CODEX_DESKTOP, + actor: { userId: fixture.user.id }, + }); + const pr = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 72, + url: "https://github.com/acme/forge/pull/72", + title: "Ship coordinated work", + state: "merged", + metadata: { + mergedAt: new Date().toISOString(), + head: { ref: "codex/ws-2-feature", sha: "abcdef1234567" }, + checks: { source: "api-aggregate", status: "completed", conclusion: "success" }, + }, + }, + }); + const merged = await attachPullRequest(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + externalResourceId: pr.id, + actor: { userId: fixture.user.id }, + }); + expect(merged.status).toBe("MERGED"); + await expect( + advanceWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + actor: { userId: fixture.user.id }, + status: "DEPLOYED", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + await expect( + advanceWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + actor: { userId: fixture.user.id }, + status: "RELEASED", + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + + const released = await advanceWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + actor: { userId: fixture.user.id }, + status: "RELEASED", + releasedVersion: "v0.20.0", + }); + expect(released.releasedVersion).toBe("v0.20.0"); + await expect( + advanceWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + actor: { userId: fixture.user.id }, + status: "DEPLOYED", + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + const deployed = await advanceWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + actor: { userId: fixture.user.id }, + status: "DEPLOYED", + deployedSha: "1234567abcdef", + }); + expect(deployed.deployedSha).toBe("1234567abcdef"); + const verified = await advanceWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + actor: { userId: fixture.user.id }, + status: "VERIFIED", + }); + expect(verified.endedAt).not.toBeNull(); + }); + + it("marks quiet leases stale and creates one shared action request", async () => { + const { fixture, prisma, issue } = await setup(); + await prisma.workspace.update({ + where: { id: fixture.workspace.id }, + data: { workSessionStaleMinutes: 1 }, + }); + const session = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/ws-3-feature", + source: WorkSessionSource.CODEX_DESKTOP, + actor: { userId: fixture.user.id }, + }); + await prisma.workSession.update({ + where: { id: session.id }, + data: { lastHeartbeatAt: new Date(Date.now() - 5 * 60_000) }, + }); + expect(await sweepStaleWorkSessions(prisma)).toBe(1); + expect((await prisma.workSession.findUniqueOrThrow({ where: { id: session.id } })).status).toBe( + "STALE", + ); + expect( + await prisma.actionRequest.count({ + where: { workspaceId: fixture.workspace.id, dedupeKey: `work-session-stale:${session.id}` }, + }), + ).toBe(1); + expect(await sweepStaleWorkSessions(prisma)).toBe(0); + }); +}); diff --git a/src/server/services/engagement-mode.ts b/src/server/services/engagement-mode.ts index 77e92da4..e901aced 100644 --- a/src/server/services/engagement-mode.ts +++ b/src/server/services/engagement-mode.ts @@ -13,7 +13,7 @@ import { * unit-testable. */ -export const FORGE_RUN_CONTRACT_VERSION = "2026-07-14.1"; +export const FORGE_RUN_CONTRACT_VERSION = "2026-07-15.1"; export type EngagementSurface = "assignment" | "queue" | "mention" | "chat" | "plan" | "watcher"; @@ -187,6 +187,15 @@ const RUN_PROTOCOL_INSTRUCTIONS = "or let runs.complete create the final comment from summary. " + "For EXECUTE, set recommendIssueCompletion true only when the verified issue outcome " + "itself is ready to close; Forge will apply the workspace safety policy. " + + "CODE WORK COORDINATION: before modifying a repository, inspect `workSessions.list` and " + + "claim the issue with `workSessions.claim`, using a dedicated non-main branch and isolated " + + "worktree. If another active session exists, do not create a competing branch; continue the " + + "owned session or ask the operator to resolve ownership. Refresh the lease with " + + "`workSessions.heartbeat` at phase changes and after commits. Link implementation PRs through " + + "Forge's native `github.link` with kind IMPLEMENTS, then attach the returned resource using " + + "`workSessions.attachPullRequest`; do not add a GitHub URL as a generic attachment. Never edit " + + "or commit in the deployment/main checkout. PR review and CI must complete before merge, and " + + "release/deploy/verification milestones require explicit operator authority. " + "Non-EXECUTE modes are read/report/review only; Forge rejects issue-state " + "mutations from those runs."; diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts index bf7ce030..ba071457 100644 --- a/src/server/services/github/resource-sync.ts +++ b/src/server/services/github/resource-sync.ts @@ -11,6 +11,7 @@ import { type StatusCategory, } from "@prisma/client"; import { recordChange } from "@/server/audit"; +import { syncWorkSessionsFromPullRequest } from "@/server/services/work-session"; import { createIssueWithSideEffects } from "@/server/services/issue-create"; import { reconcileGitHubPullRequestCompletion } from "@/server/services/completion-candidate"; import { @@ -240,6 +241,13 @@ export async function recordGitHubResourceChangeToLinkedIssues(args: { skipIssueIds?: Set; }): Promise { if (!gitHubResourceStateChanged(args.before, args.after)) return 0; + await syncWorkSessionsFromPullRequest(args.db, { + id: args.after.id, + workspaceId: args.workspaceId, + resourceType: args.after.resourceType, + state: args.after.state, + metadata: args.after.metadata, + }); const links = await args.db.externalResourceLink.findMany({ where: { workspaceId: args.workspaceId, externalResourceId: args.after.id }, select: { issueId: true }, @@ -1001,9 +1009,7 @@ export async function migrateGenericGitHubAttachments( WHERE a."kind" = 'LINK' AND a."targetType" = 'issue' AND a."targetId" IS NOT NULL - AND COALESCE(NULLIF(a."externalUrl", ''), a."url") ~* ${ - "^https?://(?:www\\.)?github\\.com/[^/]+/[^/]+/(?:issues|pull)/[0-9]+(?:[/?#].*)?$" - } + AND COALESCE(NULLIF(a."externalUrl", ''), a."url") ~* ${"^https?://(?:www\\.)?github\\.com/[^/]+/[^/]+/(?:issues|pull)/[0-9]+(?:[/?#].*)?$"} ${workspaceFilter} ORDER BY a."createdAt" ASC, a."id" ASC LIMIT ${limit} diff --git a/src/server/services/mcp-exec.ts b/src/server/services/mcp-exec.ts index 13501074..e5bae69e 100644 --- a/src/server/services/mcp-exec.ts +++ b/src/server/services/mcp-exec.ts @@ -201,6 +201,18 @@ async function resolveExplicitPolicyTarget( case "agent.inbox.outputStarted": case "runtimes.provisioning": return { kind: "none" }; + case "workSessions.claim": + return { kind: "issues", issueIds: [input.issueId as string] }; + case "workSessions.heartbeat": + case "workSessions.attachPullRequest": { + const sessionId = input.sessionId as string | undefined; + if (!sessionId) return { kind: "none" }; + const session = await db.workSession.findFirst({ + where: { id: sessionId, workspaceId: ctx.workspaceId }, + select: { issueId: true }, + }); + return session ? { kind: "issues", issueIds: [session.issueId] } : { kind: "none" }; + } } } diff --git a/src/server/services/mcp-policy.ts b/src/server/services/mcp-policy.ts index e03f6f50..670ddb00 100644 --- a/src/server/services/mcp-policy.ts +++ b/src/server/services/mcp-policy.ts @@ -214,14 +214,32 @@ export const EXPLICIT_MCP_TOOL_POLICIES = { allowedModes: "ANY", actor: "linked-agent-required", }, + "workSessions.claim": { + access: "write", + targetType: "issue", + mutationKind: "issue-state", + allowedModes: EXECUTE_ONLY, + actor: "linked-agent-required", + }, + "workSessions.heartbeat": { + access: "write", + targetType: "issue", + mutationKind: "issue-state", + allowedModes: EXECUTE_ONLY, + actor: "linked-agent-required", + }, + "workSessions.attachPullRequest": { + access: "write", + targetType: "issue", + mutationKind: "issue-state", + allowedModes: EXECUTE_ONLY, + actor: "linked-agent-required", + }, } as const satisfies Record; export type ExplicitMcpPolicyToolName = keyof typeof EXPLICIT_MCP_TOOL_POLICIES; -export function mcpToolPolicy( - name: string, - scopes: readonly string[] = [], -): McpToolPolicy { +export function mcpToolPolicy(name: string, scopes: readonly string[] = []): McpToolPolicy { const explicit = EXPLICIT_MCP_TOOL_POLICIES[name as ExplicitMcpPolicyToolName]; if (explicit) return explicit; diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index 7b43b8c2..3c6ff258 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -20,6 +20,7 @@ import { RelationKind, RuntimeKind, StatusCategory, + WorkSessionSource, type CanvasStyleKind, type EngagementMode, } from "@prisma/client"; @@ -131,6 +132,12 @@ import { GITHUB_RESOURCE_TYPES, type GitHubResourceType, } from "@/server/services/github/types"; +import { + attachPullRequest, + claimWorkSession, + listIssueWorkSessions, + touchWorkSession, +} from "@/server/services/work-session"; /** * Forge's MCP (Model Context Protocol) surface — the stable set of tools any @@ -7866,6 +7873,104 @@ export const mcpTools = { }, }, + // ---------------------------------------------------------- Work coordination + "workSessions.list": { + scopes: ["READ_ISSUES"] as const, + input: z.object({ issueId: z.string().cuid() }), + async run(input: { issueId: string }, ctx: McpContext) { + return listIssueWorkSessions(db, ctx.workspaceId, input.issueId); + }, + }, + + "workSessions.claim": { + scopes: ["WRITE_ISSUES"] as const, + input: z.object({ + issueId: z.string().cuid(), + repoFullName: z + .string() + .regex(/^[\w.-]+\/[\w.-]+$/) + .max(200), + branch: z + .string() + .regex(/^[A-Za-z0-9._/-]+$/) + .max(240), + baseBranch: z + .string() + .regex(/^[A-Za-z0-9._/-]+$/) + .max(240) + .default("main"), + worktreePath: z.string().max(1_000).nullable().optional(), + }), + async run( + input: { + issueId: string; + repoFullName: string; + branch: string; + baseBranch: string; + worktreePath?: string | null; + }, + ctx: McpContext, + ) { + const agentId = ctx.apiKey?.linkedAgentId; + if (!agentId) throw new Error("workSessions.claim requires a linked agent key."); + return claimWorkSession(db, { + workspaceId: ctx.workspaceId, + ...input, + source: WorkSessionSource.FORGE_AGENT, + actor: { userId: ctx.userId, agentId }, + }); + }, + }, + + "workSessions.heartbeat": { + scopes: ["WRITE_ISSUES"] as const, + input: z.object({ + sessionId: z.string().cuid(), + headSha: z + .string() + .regex(/^[a-f0-9]{7,64}$/i) + .nullable() + .optional(), + worktreePath: z.string().max(1_000).nullable().optional(), + }), + async run( + input: { sessionId: string; headSha?: string | null; worktreePath?: string | null }, + ctx: McpContext, + ) { + const agentId = ctx.apiKey?.linkedAgentId; + if (!agentId) throw new Error("workSessions.heartbeat requires a linked agent key."); + const owned = await db.workSession.findFirst({ + where: { id: input.sessionId, workspaceId: ctx.workspaceId, ownerAgentId: agentId }, + select: { id: true }, + }); + if (!owned) throw new Error("Work session is not owned by this agent."); + return touchWorkSession(db, { + workspaceId: ctx.workspaceId, + ...input, + actor: { userId: ctx.userId, agentId }, + }); + }, + }, + + "workSessions.attachPullRequest": { + scopes: ["WRITE_ISSUES"] as const, + input: z.object({ sessionId: z.string().cuid(), externalResourceId: z.string().cuid() }), + async run(input: { sessionId: string; externalResourceId: string }, ctx: McpContext) { + const agentId = ctx.apiKey?.linkedAgentId; + if (!agentId) throw new Error("workSessions.attachPullRequest requires a linked agent key."); + const owned = await db.workSession.findFirst({ + where: { id: input.sessionId, workspaceId: ctx.workspaceId, ownerAgentId: agentId }, + select: { id: true }, + }); + if (!owned) throw new Error("Work session is not owned by this agent."); + return attachPullRequest(db, { + workspaceId: ctx.workspaceId, + ...input, + actor: { userId: ctx.userId, agentId }, + }); + }, + }, + // --------------------------------------------------------------------- GitHub "github.parseUrl": { scopes: ["READ_ISSUES"] as const, @@ -14082,7 +14187,16 @@ export const MCP_DEFAULT_PROFILE = "runtime"; export const MCP_TOOL_PROFILES: Record = { // Agent runtime essentials: enough to read/act on issues, chat, runs, and // human approval requests while leaving budget for other MCP/native tools. - runtime: ["issues", "comments", "chat", "runs", "actionRequests", "workspace"], + runtime: [ + "issues", + "comments", + "chat", + "runs", + "workSessions", + "actionRequests.list", + "actionRequests.create", + "workspace", + ], // Everyday issue tracking — the smallest useful working set. core: [ "issues", @@ -14096,6 +14210,7 @@ export const MCP_TOOL_PROFILES: Record = { "workspace", "workspaces", "events", + "workSessions", "pins", "notes", ], @@ -14119,6 +14234,7 @@ export const MCP_TOOL_PROFILES: Record = { "workspace", "workspaces", "events", + "workSessions", ], // core + agent orchestration / runtime ops. agents: [ @@ -14135,6 +14251,7 @@ export const MCP_TOOL_PROFILES: Record = { "workspace", "workspaces", "events", + "workSessions", ], // Visual canvas tooling (the largest single namespace). canvas: ["canvases", "artifacts", "attachments", "notes", "workspace", "workspaces"], @@ -14167,7 +14284,7 @@ export function selectMcpToolNames(opts: { MCP_TOOL_PROFILES[MCP_DEFAULT_PROFILE]); if (nsList) { const allow = new Set(nsList); - names = names.filter((n) => allow.has(mcpToolNamespace(n))); + names = names.filter((n) => allow.has(n) || allow.has(mcpToolNamespace(n))); } if (opts.scopes) { diff --git a/src/server/services/work-session.ts b/src/server/services/work-session.ts new file mode 100644 index 00000000..397dd670 --- /dev/null +++ b/src/server/services/work-session.ts @@ -0,0 +1,546 @@ +import "server-only"; + +import { + ActionRequestKind, + ActionRequestStatus, + EventKind, + NotificationSeverity, + WorkSessionStatus, +} from "@prisma/client"; +import type { Prisma, PrismaClient, WorkSessionSource } from "@prisma/client"; +import { TRPCError } from "@trpc/server"; +import { recordChange } from "@/server/audit"; +import { createActionRequest } from "@/server/services/action-request-service"; + +type DbClient = PrismaClient | Prisma.TransactionClient; + +export const ACTIVE_WORK_SESSION_STATUSES: readonly WorkSessionStatus[] = [ + WorkSessionStatus.CLAIMED, + WorkSessionStatus.IN_PROGRESS, + WorkSessionStatus.PR_OPEN, + WorkSessionStatus.IN_REVIEW, + WorkSessionStatus.READY_TO_MERGE, + WorkSessionStatus.MERGED, + WorkSessionStatus.RELEASED, + WorkSessionStatus.DEPLOYED, + WorkSessionStatus.STALE, +]; + +const DELIVERY_STATUSES = new Set([ + WorkSessionStatus.MERGED, + WorkSessionStatus.RELEASED, + WorkSessionStatus.DEPLOYED, + WorkSessionStatus.VERIFIED, +]); + +const STALEABLE_WORK_SESSION_STATUSES: readonly WorkSessionStatus[] = [ + WorkSessionStatus.CLAIMED, + WorkSessionStatus.IN_PROGRESS, + WorkSessionStatus.PR_OPEN, + WorkSessionStatus.IN_REVIEW, + WorkSessionStatus.READY_TO_MERGE, +]; + +export type WorkSessionActor = { + userId: string | null; + agentId?: string | null; +}; + +async function resolveStaleRequest( + db: PrismaClient, + workspaceId: string, + sessionId: string, + resolution: string, +) { + await db.actionRequest.updateMany({ + where: { + workspaceId, + dedupeKey: `work-session-stale:${sessionId}`, + status: ActionRequestStatus.OPEN, + }, + data: { + status: ActionRequestStatus.RESOLVED, + resolvedAt: new Date(), + resolution, + }, + }); +} + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function auditJson(value: unknown): Prisma.InputJsonValue | undefined { + if (value === undefined) return undefined; + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +} + +function isPassingChecks(metadata: unknown): boolean { + const checks = record(record(metadata).checks); + return ( + checks.source === "api-aggregate" && + checks.status === "completed" && + typeof checks.conclusion === "string" && + ["success", "neutral", "skipped"].includes(checks.conclusion) + ); +} + +function reviewDecision(metadata: unknown): string | null { + const meta = record(metadata); + const review = record(meta.review); + return typeof meta.reviewDecision === "string" + ? meta.reviewDecision + : typeof review.decision === "string" + ? review.decision + : null; +} + +export function deriveWorkSessionPrStatus(resource: { + state: string; + metadata: unknown; +}): WorkSessionStatus { + if (resource.state === "merged") return WorkSessionStatus.MERGED; + if (resource.state === "draft") return WorkSessionStatus.PR_OPEN; + const meta = record(resource.metadata); + const decision = reviewDecision(resource.metadata); + const mergeable = typeof meta.mergeableState === "string" ? meta.mergeableState : null; + if ( + resource.state === "open" && + decision === "APPROVED" && + isPassingChecks(resource.metadata) && + mergeable !== "dirty" && + mergeable !== "blocked" + ) { + return WorkSessionStatus.READY_TO_MERGE; + } + return WorkSessionStatus.IN_REVIEW; +} + +async function auditSession( + db: DbClient, + session: { id: string; workspaceId: string; issueId: string }, + actor: WorkSessionActor, + action: string, + before: unknown, + after: unknown, +) { + await recordChange(db, { + workspaceId: session.workspaceId, + actorId: actor.userId, + actorAgentId: actor.agentId ?? null, + entity: "WorkSession", + entityId: session.id, + action, + before: auditJson(before), + after: auditJson(after), + eventKind: EventKind.ISSUE_UPDATED, + subjectType: "issue", + subjectId: session.issueId, + payload: { action, workSessionId: session.id }, + }); +} + +export async function claimWorkSession( + db: PrismaClient, + input: { + workspaceId: string; + issueId: string; + repoFullName: string; + branch: string; + baseBranch?: string; + worktreePath?: string | null; + source: WorkSessionSource; + actor: WorkSessionActor; + }, +) { + if (!input.actor.userId && !input.actor.agentId) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "A work-session owner is required." }); + } + const result = await db.$transaction(async (tx) => { + const issue = await tx.issue.findFirst({ + where: { id: input.issueId, workspaceId: input.workspaceId, deletedAt: null }, + select: { id: true }, + }); + if (!issue) throw new TRPCError({ code: "NOT_FOUND", message: "Issue not found." }); + + const active = await tx.workSession.findFirst({ + where: { issueId: input.issueId, status: { in: [...ACTIVE_WORK_SESSION_STATUSES] } }, + include: { + ownerUser: { select: { id: true, name: true, email: true } }, + ownerAgent: { select: { id: true, name: true, profileKey: true } }, + }, + }); + if (active) { + const sameOwner = input.actor.agentId + ? active.ownerAgentId === input.actor.agentId + : active.ownerUserId === input.actor.userId; + if ( + sameOwner && + active.repoFullName === input.repoFullName && + active.branch === input.branch + ) { + return tx.workSession.update({ + where: { id: active.id }, + data: { + lastHeartbeatAt: new Date(), + staleAt: null, + status: + active.status === WorkSessionStatus.STALE + ? WorkSessionStatus.IN_PROGRESS + : active.status, + worktreePath: input.worktreePath ?? active.worktreePath, + }, + }); + } + const owner = + active.ownerAgent?.name ?? + active.ownerUser?.name ?? + active.ownerUser?.email ?? + "another task"; + throw new TRPCError({ + code: "CONFLICT", + message: `${owner} already owns active work on ${active.branch}. Continue that session or abandon it before starting another.`, + }); + } + + const created = await tx.workSession.create({ + data: { + workspaceId: input.workspaceId, + issueId: input.issueId, + ownerUserId: input.actor.userId, + ownerAgentId: input.actor.agentId ?? null, + source: input.source, + status: WorkSessionStatus.CLAIMED, + repoFullName: input.repoFullName, + branch: input.branch, + baseBranch: input.baseBranch || "main", + worktreePath: input.worktreePath ?? null, + }, + }); + await auditSession(tx, created, input.actor, "work-session-claimed", undefined, created); + return created; + }); + await resolveStaleRequest(db, input.workspaceId, result.id, "Work session resumed."); + return result; +} + +export async function listIssueWorkSessions( + db: PrismaClient, + workspaceId: string, + issueId: string, +) { + const issue = await db.issue.findFirst({ + where: { id: issueId, workspaceId, deletedAt: null }, + select: { id: true }, + }); + if (!issue) throw new TRPCError({ code: "NOT_FOUND", message: "Issue not found." }); + + const sessions = await db.workSession.findMany({ + where: { workspaceId, issueId }, + orderBy: { createdAt: "desc" }, + include: { + ownerUser: { select: { id: true, name: true, email: true, image: true } }, + ownerAgent: { select: { id: true, name: true, profileKey: true, avatar: true } }, + pullRequest: { + select: { + id: true, + number: true, + repoFullName: true, + url: true, + title: true, + state: true, + metadata: true, + lastSyncedAt: true, + syncLastError: true, + }, + }, + }, + }); + + // Auto-bind an IMPLEMENTS PR whose head branch matches the session. This is + // intentionally a repair path: explicit linking remains preferred. + const unbound = sessions.find( + (session) => !session.pullRequestId && !DELIVERY_STATUSES.has(session.status), + ); + if (unbound) { + const links = await db.externalResourceLink.findMany({ + where: { + workspaceId, + issueId, + kind: "IMPLEMENTS", + externalResource: { resourceType: "PULL_REQUEST" }, + }, + include: { externalResource: true }, + }); + const match = links.find( + (link) => record(record(link.externalResource.metadata).head).ref === unbound.branch, + ); + if (match) { + await attachPullRequest(db, { + workspaceId, + sessionId: unbound.id, + externalResourceId: match.externalResourceId, + actor: { userId: null }, + }); + return listIssueWorkSessions(db, workspaceId, issueId); + } + } + return sessions; +} + +export async function touchWorkSession( + db: PrismaClient, + input: { + workspaceId: string; + sessionId: string; + actor: WorkSessionActor; + headSha?: string | null; + worktreePath?: string | null; + }, +) { + const session = await db.workSession.findFirst({ + where: { id: input.sessionId, workspaceId: input.workspaceId }, + }); + if (!session) throw new TRPCError({ code: "NOT_FOUND", message: "Work session not found." }); + const updated = await db.workSession.update({ + where: { id: session.id }, + data: { + lastHeartbeatAt: new Date(), + staleAt: null, + status: + session.status === WorkSessionStatus.CLAIMED || session.status === WorkSessionStatus.STALE + ? WorkSessionStatus.IN_PROGRESS + : session.status, + ...(input.headSha !== undefined ? { headSha: input.headSha } : {}), + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }, + }); + await resolveStaleRequest(db, input.workspaceId, session.id, "Work session resumed."); + return updated; +} + +export async function attachPullRequest( + db: PrismaClient, + input: { + workspaceId: string; + sessionId: string; + externalResourceId: string; + actor: WorkSessionActor; + }, +) { + const result = await db.$transaction(async (tx) => { + const session = await tx.workSession.findFirst({ + where: { id: input.sessionId, workspaceId: input.workspaceId }, + }); + if (!session) throw new TRPCError({ code: "NOT_FOUND", message: "Work session not found." }); + const resource = await tx.externalResource.findFirst({ + where: { + id: input.externalResourceId, + workspaceId: input.workspaceId, + resourceType: "PULL_REQUEST", + }, + }); + if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Pull request not found." }); + const head = record(record(resource.metadata).head); + if (typeof head.ref === "string" && head.ref !== session.branch) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `PR branch ${head.ref} does not match work session branch ${session.branch}.`, + }); + } + const derived = deriveWorkSessionPrStatus(resource); + const now = new Date(); + const updated = await tx.workSession.update({ + where: { id: session.id }, + data: { + pullRequestId: resource.id, + headSha: typeof head.sha === "string" ? head.sha : session.headSha, + status: DELIVERY_STATUSES.has(session.status) ? session.status : derived, + lastHeartbeatAt: now, + ...(derived === WorkSessionStatus.MERGED + ? { + mergedAt: + typeof record(resource.metadata).mergedAt === "string" + ? new Date(record(resource.metadata).mergedAt as string) + : now, + } + : {}), + }, + }); + await auditSession(tx, session, input.actor, "work-session-pr-linked", session, updated); + return updated; + }); + return result; +} + +/** Mirror authoritative native GitHub PR state onto every attached session. */ +export async function syncWorkSessionsFromPullRequest( + db: DbClient, + resource: { + id: string; + workspaceId: string; + resourceType: string; + state: string; + metadata: unknown; + }, +): Promise { + if (resource.resourceType !== "PULL_REQUEST") return 0; + const sessions = await db.workSession.findMany({ + where: { workspaceId: resource.workspaceId, pullRequestId: resource.id }, + }); + const derived = deriveWorkSessionPrStatus(resource); + const meta = record(resource.metadata); + const head = record(meta.head); + let count = 0; + for (const session of sessions) { + if (DELIVERY_STATUSES.has(session.status) && derived !== WorkSessionStatus.MERGED) continue; + const next = DELIVERY_STATUSES.has(session.status) ? session.status : derived; + await db.workSession.update({ + where: { id: session.id }, + data: { + status: next, + headSha: typeof head.sha === "string" ? head.sha : session.headSha, + lastHeartbeatAt: new Date(), + staleAt: null, + ...(derived === WorkSessionStatus.MERGED && !session.mergedAt + ? { + mergedAt: typeof meta.mergedAt === "string" ? new Date(meta.mergedAt) : new Date(), + } + : {}), + }, + }); + count += 1; + } + return count; +} + +export async function advanceWorkSession( + db: PrismaClient, + input: { + workspaceId: string; + sessionId: string; + actor: WorkSessionActor; + status: "RELEASED" | "DEPLOYED" | "VERIFIED" | "ABANDONED"; + releasedVersion?: string | null; + deployedSha?: string | null; + }, +) { + const result = await db.$transaction(async (tx) => { + const session = await tx.workSession.findFirst({ + where: { id: input.sessionId, workspaceId: input.workspaceId }, + }); + if (!session) throw new TRPCError({ code: "NOT_FOUND", message: "Work session not found." }); + const next = WorkSessionStatus[input.status]; + if (next !== WorkSessionStatus.ABANDONED) { + const allowed = + (next === WorkSessionStatus.RELEASED && session.status === WorkSessionStatus.MERGED) || + (next === WorkSessionStatus.DEPLOYED && session.status === WorkSessionStatus.RELEASED) || + (next === WorkSessionStatus.VERIFIED && session.status === WorkSessionStatus.DEPLOYED); + if (!allowed) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Cannot move ${session.status.toLowerCase()} work directly to ${input.status.toLowerCase()}.`, + }); + } + } + const releasedVersion = input.releasedVersion?.trim(); + if (next === WorkSessionStatus.RELEASED && !releasedVersion) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "A release version is required before work can be marked released.", + }); + } + const deployedSha = input.deployedSha?.trim(); + if (next === WorkSessionStatus.DEPLOYED && !deployedSha) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The exact deployed commit SHA is required before work can be marked deployed.", + }); + } + const now = new Date(); + const updated = await tx.workSession.update({ + where: { id: session.id }, + data: { + status: next, + lastHeartbeatAt: now, + ...(next === WorkSessionStatus.RELEASED + ? { releasedAt: now, releasedVersion } + : {}), + ...(next === WorkSessionStatus.DEPLOYED + ? { deployedAt: now, deployedSha } + : {}), + ...(next === WorkSessionStatus.VERIFIED ? { verifiedAt: now, endedAt: now } : {}), + ...(next === WorkSessionStatus.ABANDONED ? { endedAt: now } : {}), + }, + }); + await auditSession( + tx, + session, + input.actor, + `work-session-${input.status.toLowerCase()}`, + session, + updated, + ); + return updated; + }); + if (input.status === "ABANDONED" || input.status === "VERIFIED") { + await resolveStaleRequest( + db, + input.workspaceId, + input.sessionId, + input.status === "VERIFIED" ? "Deployment verified." : "Work session abandoned.", + ); + } + return result; +} + +export async function sweepStaleWorkSessions(db: PrismaClient): Promise { + const workspaces = await db.workspace.findMany({ + where: { deletedAt: null, workSessionStaleMinutes: { gt: 0 } }, + select: { id: true, workSessionStaleMinutes: true }, + }); + let count = 0; + for (const workspace of workspaces) { + const cutoff = new Date(Date.now() - workspace.workSessionStaleMinutes * 60_000); + const sessions = await db.workSession.findMany({ + where: { + workspaceId: workspace.id, + status: { in: [...STALEABLE_WORK_SESSION_STATUSES] }, + lastHeartbeatAt: { lt: cutoff }, + }, + }); + for (const session of sessions) { + await db.$transaction(async (tx) => { + const updated = await tx.workSession.update({ + where: { id: session.id }, + data: { status: WorkSessionStatus.STALE, staleAt: new Date() }, + }); + await auditSession(tx, session, { userId: null }, "work-session-stale", session, updated); + }); + const issue = await db.issue.findUnique({ + where: { id: session.issueId }, + select: { authorId: true, workspace: { select: { key: true } }, number: true }, + }); + await createActionRequest(db, { + workspaceId: workspace.id, + actorId: null, + title: `Work on ${issue ? `${issue.workspace.key}-${issue.number}` : "an issue"} went quiet`, + body: + `${session.repoFullName}:${session.branch} has not checked in for ` + + `${workspace.workSessionStaleMinutes} minutes. Resume the existing session or abandon it before starting replacement work.`, + severity: NotificationSeverity.WARNING, + kind: ActionRequestKind.FREE_FORM, + assignedUserId: session.ownerUserId ?? issue?.authorId ?? null, + assignedAgentId: session.ownerAgentId, + sourceType: "work-session", + sourceId: session.id, + dedupeKey: `work-session-stale:${session.id}`, + issueId: session.issueId, + }); + count += 1; + } + } + return count; +} diff --git a/src/server/worker.ts b/src/server/worker.ts index 5aaf52c1..c5106333 100644 --- a/src/server/worker.ts +++ b/src/server/worker.ts @@ -31,6 +31,7 @@ import { sweepIdleEphemeralAgents } from "@/server/services/ephemeral-idle"; import { sweepCompletionCandidates } from "@/server/services/completion-candidate"; import { sweepGitHubStatusReconciliation } from "@/server/services/github/reconciliation"; import { recoverGenericGitHubAttachments } from "@/server/services/github/resource-sync"; +import { sweepStaleWorkSessions } from "@/server/services/work-session"; import { logger } from "@/server/logger"; import { webhookQueue, maintenanceQueue } from "@/server/queues"; @@ -64,6 +65,8 @@ const COMPLETION_CANDIDATE_SWEEP_INTERVAL_MS = 5 * 60_000; const COMPLETION_CANDIDATE_SWEEP_JOB_ID = "completion-candidate-sweep"; const GITHUB_RECONCILIATION_SWEEP_INTERVAL_MS = 5 * 60_000; const GITHUB_RECONCILIATION_SWEEP_JOB_ID = "github-reconciliation-sweep"; +const WORK_SESSION_SWEEP_INTERVAL_MS = 5 * 60_000; +const WORK_SESSION_SWEEP_JOB_ID = "work-session-stale-sweep"; export { webhookQueue, maintenanceQueue }; export const webhookEvents = new QueueEvents("webhooks", { connection }); @@ -330,6 +333,9 @@ export const maintenanceWorker = new Worker( const reconciliation = await sweepGitHubStatusReconciliation(db); return { recoveredAttachments, reconciliation }; } + case "work-session-stale-sweep": { + return sweepStaleWorkSessions(db); + } case "required-ack-check": { const eventId = job.data?.agentAssignedEventId as string | undefined; if (!eventId) return null; @@ -604,6 +610,20 @@ export async function registerGitHubReconciliationSweepJob(): Promise { ); } +/** Mark abandoned branch/worktree leases stale without silently releasing them. */ +export async function registerWorkSessionSweepJob(): Promise { + await maintenanceQueue.add( + "work-session-stale-sweep", + {}, + { + jobId: WORK_SESSION_SWEEP_JOB_ID, + repeat: { every: WORK_SESSION_SWEEP_INTERVAL_MS }, + removeOnComplete: { age: 3600, count: 100 }, + removeOnFail: { age: 86_400, count: 50 }, + }, + ); +} + // Auto-register recurring jobs when this module loads (i.e. when // `pnpm worker` boots). Fire-and-forget — a Redis outage at boot should // not crash the worker; BullMQ will retry internally on the next op. @@ -646,6 +666,9 @@ void registerCompletionCandidateSweepJob().catch((err) => { void registerGitHubReconciliationSweepJob().catch((err) => { logger.warn({ err }, "failed to register github-reconciliation-sweep job"); }); +void registerWorkSessionSweepJob().catch((err) => { + logger.warn({ err }, "failed to register work-session-stale-sweep job"); +}); if (import.meta.url === `file://${process.argv[1]}`) { logger.info("workers running"); diff --git a/tests/e2e/dashboard-layout.spec.ts b/tests/e2e/dashboard-layout.spec.ts index a9603b45..f0b0fec5 100644 --- a/tests/e2e/dashboard-layout.spec.ts +++ b/tests/e2e/dashboard-layout.spec.ts @@ -54,7 +54,14 @@ test("dashboard keeps priority work bounded and reflows secondary modules", asyn .locator(":scope > [data-widget-id]") .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-widget-id"))), ) - .toEqual(["agent-attention", "agent-activity", "standup", "pulse", "today"]); + .toEqual([ + "agent-attention", + "delivery-work", + "agent-activity", + "standup", + "pulse", + "today", + ]); const priorityColumns = await priorityGrid.evaluate((node) => { const tracks = getComputedStyle(node).gridTemplateColumns; return tracks === "none" ? 1 : tracks.split(" ").length; diff --git a/tests/unit/mcp-tool-profiles.test.ts b/tests/unit/mcp-tool-profiles.test.ts index 40ef64a4..cb40463c 100644 --- a/tests/unit/mcp-tool-profiles.test.ts +++ b/tests/unit/mcp-tool-profiles.test.ts @@ -35,8 +35,8 @@ describe("mcp tool profiles (AXI-82)", () => { ); }); - it("every named profile references only real namespaces", () => { - const known = new Set(mcpNamespaces); + it("every named profile references only real namespaces or tools", () => { + const known = new Set([...mcpNamespaces, ...mcpToolNames]); for (const [name, namespaces] of Object.entries(MCP_TOOL_PROFILES)) { for (const ns of namespaces) { expect(known, `profile "${name}" → unknown namespace "${ns}"`).toContain(ns); @@ -74,6 +74,7 @@ describe("mcp tool profiles (AXI-82)", () => { expect(runtime).toContain("comments.create"); expect(runtime).toContain("runs.complete"); expect(runtime).toContain("actionRequests.list"); + expect(runtime).toContain("workSessions.claim"); expect(runtime.some((n) => mcpToolNamespace(n) === "canvases")).toBe(false); }); From 51b2501886971c45dd47eda883b64a327628bbdf Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Wed, 15 Jul 2026 12:55:10 -0400 Subject: [PATCH 2/2] fix: enforce work session scope and heartbeat safety --- DEVLOG.md | 7 ++ .../services/__tests__/work-session.test.ts | 77 +++++++++++++++++++ src/server/services/mcp.ts | 2 + src/server/services/work-session.ts | 18 +++-- 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 86c6f807..5a5de276 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -43,6 +43,13 @@ dashboard widget expectation corrected and both affected specs passing in a serial follow-up **4/4**. The unrelated chat `/clear` timing case also passed in that serial follow-up. +The pre-merge Codex review identified two narrowing-boundary omissions and one +heartbeat race. `workSessions.list` and `workSessions.claim` now enforce the +calling API key's project/label/initiative scope before reading or leasing an +issue. The stale sweep now atomically rechecks both status and heartbeat cutoff +when claiming a session for transition, so a concurrent heartbeat wins instead +of being overwritten. Focused regression coverage now passes **13/13**. + ## 2026-07-15 — Read-only GitHub sync health refresh Closed the post-release usability gap in workspace GitHub App settings. Added a diff --git a/src/server/services/__tests__/work-session.test.ts b/src/server/services/__tests__/work-session.test.ts index 8723bb48..53e77ca3 100644 --- a/src/server/services/__tests__/work-session.test.ts +++ b/src/server/services/__tests__/work-session.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, describe, expect, it } from "vitest"; import { WorkSessionSource } from "@prisma/client"; +import { mcpTools, type McpContext } from "@/server/services/mcp"; import { advanceWorkSession, attachPullRequest, @@ -176,4 +177,80 @@ describe("work session coordination", () => { ).toBe(1); expect(await sweepStaleWorkSessions(prisma)).toBe(0); }); + + it("enforces narrowed API-key scope for listing and claiming sessions", async () => { + const { fixture, prisma, issue } = await setup(); + const allowedProject = await prisma.project.create({ + data: { + workspaceId: fixture.workspace.id, + key: `AL${Date.now().toString().slice(-6)}`, + name: "Allowed lane", + createdById: fixture.user.id, + }, + }); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `scoped-${Date.now()}`, + name: "Scoped agent", + }, + }); + const ctx = { + workspaceId: fixture.workspace.id, + userId: fixture.user.id, + pluginId: null, + apiKey: { + keyId: "scoped-key", + workspaceId: fixture.workspace.id, + userId: fixture.user.id, + pluginId: null, + scopes: ["READ_ISSUES", "WRITE_ISSUES"], + projectIds: [allowedProject.id], + labelIds: [], + initiativeIds: [], + linkedAgentId: agent.id, + }, + } as unknown as McpContext; + + await expect( + mcpTools["workSessions.list"].run({ issueId: issue.id } as never, ctx), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect( + mcpTools["workSessions.claim"].run( + { + issueId: issue.id, + repoFullName: "acme/forge", + branch: "agent/out-of-scope", + baseBranch: "main", + } as never, + ctx, + ), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(await prisma.workSession.count({ where: { issueId: issue.id } })).toBe(0); + }); + + it("leaves recently refreshed work active during the stale sweep", async () => { + const { fixture, prisma, issue } = await setup(); + await prisma.workspace.update({ + where: { id: fixture.workspace.id }, + data: { workSessionStaleMinutes: 1 }, + }); + const session = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/refreshed-before-sweep", + source: WorkSessionSource.CODEX_DESKTOP, + actor: { userId: fixture.user.id }, + }); + await prisma.workSession.update({ + where: { id: session.id }, + data: { lastHeartbeatAt: new Date() }, + }); + + expect(await sweepStaleWorkSessions(prisma)).toBe(0); + expect((await prisma.workSession.findUniqueOrThrow({ where: { id: session.id } })).status).toBe( + "CLAIMED", + ); + }); }); diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index 3c6ff258..2e7a2ef0 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -7878,6 +7878,7 @@ export const mcpTools = { scopes: ["READ_ISSUES"] as const, input: z.object({ issueId: z.string().cuid() }), async run(input: { issueId: string }, ctx: McpContext) { + await assertKeyScope(scopeCtx(ctx), { entity: "issue", id: input.issueId }); return listIssueWorkSessions(db, ctx.workspaceId, input.issueId); }, }, @@ -7913,6 +7914,7 @@ export const mcpTools = { ) { const agentId = ctx.apiKey?.linkedAgentId; if (!agentId) throw new Error("workSessions.claim requires a linked agent key."); + await assertKeyScope(scopeCtx(ctx), { entity: "issue", id: input.issueId }); return claimWorkSession(db, { workspaceId: ctx.workspaceId, ...input, diff --git a/src/server/services/work-session.ts b/src/server/services/work-session.ts index 397dd670..8fe01c99 100644 --- a/src/server/services/work-session.ts +++ b/src/server/services/work-session.ts @@ -512,13 +512,21 @@ export async function sweepStaleWorkSessions(db: PrismaClient): Promise }, }); for (const session of sessions) { - await db.$transaction(async (tx) => { - const updated = await tx.workSession.update({ - where: { id: session.id }, + const markedStale = await db.$transaction(async (tx) => { + const claimed = await tx.workSession.updateMany({ + where: { + id: session.id, + status: { in: [...STALEABLE_WORK_SESSION_STATUSES] }, + lastHeartbeatAt: { lt: cutoff }, + }, data: { status: WorkSessionStatus.STALE, staleAt: new Date() }, }); + if (claimed.count === 0) return null; + const updated = await tx.workSession.findUniqueOrThrow({ where: { id: session.id } }); await auditSession(tx, session, { userId: null }, "work-session-stale", session, updated); + return updated; }); + if (!markedStale) continue; const issue = await db.issue.findUnique({ where: { id: session.issueId }, select: { authorId: true, workspace: { select: { key: true } }, number: true }, @@ -532,8 +540,8 @@ export async function sweepStaleWorkSessions(db: PrismaClient): Promise `${workspace.workSessionStaleMinutes} minutes. Resume the existing session or abandon it before starting replacement work.`, severity: NotificationSeverity.WARNING, kind: ActionRequestKind.FREE_FORM, - assignedUserId: session.ownerUserId ?? issue?.authorId ?? null, - assignedAgentId: session.ownerAgentId, + assignedUserId: markedStale.ownerUserId ?? issue?.authorId ?? null, + assignedAgentId: markedStale.ownerAgentId, sourceType: "work-session", sourceId: session.id, dedupeKey: `work-session-stale:${session.id}`,