Skip to content

Workflow boards: kanban state machines that drive coding agents#3032

Open
ccdwyer wants to merge 35 commits into
pingdotgg:mainfrom
ccdwyer:ft/hyperion
Open

Workflow boards: kanban state machines that drive coding agents#3032
ccdwyer wants to merge 35 commits into
pingdotgg:mainfrom
ccdwyer:ft/hyperion

Conversation

@ccdwyer

@ccdwyer ccdwyer commented Jun 10, 2026

Copy link
Copy Markdown

Workflow Boards

Per-project kanban boards as event-sourced state machines that drive coding agents. Lanes hold pipelines of steps (agent / script / approval / merge); routing between lanes is decided by step outcomes, JSONLogic predicates over captured output, lane fallbacks, manual actions, or external webhook events. Every ticket gets its own git worktree, every move is audited and explained.

All screenshots below are from a live run on a mock project ("Snackbase"): the board's agent steps run GPT-5.5 at different reasoning levels per lane (planning = low, implementation = medium with escalation to extra-high on retry, review = high ×3 reviewers), and the "Fix off-by-one" ticket was driven through the pipeline by real agents.

The board

Lanes with per-lane colors and WIP limits, tickets with status stripes, dependency badges ("waiting on 1 dependency"), token budgets ("0 tok / 250k"), and usage roll-ups.

Board overview

Creating a ticket: description, blocked-by dependencies, and an optional token budget that halts agent steps once spent.

New ticket

Intake: braindump → tickets

Paste a braindump, pick the agent (provider/model + reasoning effort), and it proposes structured tickets — including dependency edges ("After #1") — which you edit and approve before anything is created. These proposals came from a real GPT-5.5 run.

Intake braindump

Intake proposals

The workflow editor

Canvas view: lanes as cards, steps typed and colored, routing edges colored by outcome (success/failure/blocked), numbered transitions, dotted action edges, routing-precedence legend. Edits are drag-to-connect or via the inspector; explicit Save lints and writes the board file (.t3/boards/*.json is the source of truth).

Editor canvas

Selecting a lane dims every edge that doesn't touch it, so dense graphs stay readable:

Lane focus dimming

Agent steps, fully configurable

The implement step: GPT-5.5 · Medium reasoning, 2 retry attempts, and "Escalate on retry" to GPT-5.5 · Extra High — a failed attempt automatically reruns on the stronger configuration.

Implement step

The review step: GPT-5.5 · High, captured output (the agent ends with a fenced JSON verdict that routing predicates can read), and a 3-reviewer panel — three independent sessions vote, strict majority wins.

Review step panel

Lane form: merge steps, routing, external events

The Land lane in form view: a merge step (commits the ticket worktree and merges it into the checked-out branch; conflicts block instead of failing), lane success/failure/blocked routes, and external event matchers — a ci.passed webhook with a payload predicate moves the ticket to Done.

Land lane form

Dry run

Simulate a hypothetical ticket through the definition you're editing (unsaved changes included) under all-succeed / all-fail / all-block scenarios. It mirrors the engine's exact routing semantics and explains every hop — here it correctly flags that the success path stalls in Review unless a verdict transition matches.

Dry run

Version history

Every save is snapshotted per board with diffs and non-destructive revert.

Version history

External events

Each board gets a webhook endpoint with a rotating token (shown exactly once) and a copyable curl example. CI, PR automation, or cron can move correlated tickets (by ticketId or workflow/<id> branch) through their lane's event matchers, with delivery dedupe.

Webhook config

The board reports to you

A digest of the last 24h: shipped/created counts, tokens spent, agent time, and which tickets are waiting on a human.

Board digest

Living with a ticket

The drawer: "Why is this ticket here?" route explainability (every hop with the rule that caused it), a discussion thread whose comments reach the next agent step as context, per-step status/duration/token usage, and one-click lane actions ("Retry build", "Back to backlog").

Ticket drawer

Script steps are gated by per-project trust — the first node --test run blocks until you allow it:

Trust gate

Every ticket has a case file (.t3/ticket/<id>/) the agents write into — here the PLAN.md the planning agent produced — plus the script output and reviewer sessions:

Artifacts

And any agent step's full session is one click away, read-only:

Agent session

Boards live in the sidebar with hover rename/delete (delete cascades tickets, events, versions, worktrees, and webhook tokens):

Sidebar

Not shown but included

Durable restart recovery (pipelines, retries, merges, approvals resume safely), WIP queueing with FIFO auto-admission, dependency auto-release, terminal-lane retention TTL with full state cleanup, aging badges and waiting-on-you toasts, ticket search, multi-environment boards, and an event-sourced audit trail under everything.

Notes

  • The engine is a new bounded context under apps/server/src/workflow/** (Effect TS, event-sourced over SQLite) with contracts in packages/contracts/src/workflow.ts and the web UI under apps/web/src/components/board/**.
  • Every batch on this branch went through adversarial review (security findings like webhook token hashing, prototype-pollution sanitization, and stale-event fencing came out of those rounds) plus live Playwright verification.
  • docs/workflow-demo/ (these screenshots) is demo material and can be dropped before merge.

🤖 Generated with Claude Code

Note

Add workflow kanban boards with state machines, agents, and mobile ticket notifications

  • Introduces a full workflow board system: boards are defined as JSON state machines with lanes, steps (agent/script/merge/PR/approval), and JSONLogic-based routing; a lintWorkflowDefinition validator and dryRun simulator are included.
  • Adds server-side WorkflowEngine, WorkflowEventStore, ProjectionPipeline, ReadModel, and supporting layers (BoardRegistry, WorktreeLeaseService, WebhookService, TicketMergeService, TicketPullRequestService, ScriptStepExecutor, etc.) wired into the server runtime via WorkflowRuntimeLive.
  • Exposes ~30 new WebSocket RPCs (workflow.*) and a terminal.attachHistory stream; the client-side EnvironmentApi and WsRpcClient are updated to match.
  • Adds a React board UI with a DnD-kit BoardView, TicketCard, TicketDrawer, BoardHeaderControls (new ticket, intake, webhook, digest), a full-screen WorkflowEditor with canvas, lane/step/routing forms, dry-run panel, and version history diff.
  • Adds sidebar board management (list, create, rename, delete with confirmation) and routing to /$environmentId/board?boardId=....
  • Introduces relay BoardTicketPublisher and publishBoardTicket endpoint; mobile gains a NeedsYouInboxScreen and TicketActionSheetScreen with ticket deep-link support.
  • Fixes premature turn settlement: assistant messages no longer complete a turn while the session is still running; turns only settle on session status transition away from running.
  • Risk: large schema migration (migrations 033–034) adds workflow tables and alters projection_ticket; this is a destructive change on existing databases.

Macroscope summarized e447e02.


Note

High Risk
Large schema migration consolidation and orchestration turn-lifecycle changes affect core session projection behavior; mobile ticket mutations touch workflow operate paths from notifications.

Overview
Mobile adds workflow ticket handling end-to-end: a Needs you inbox that aggregates listNeedsAttentionTickets across environments, a ticket action sheet (answer, approve, move lane, comment) with cold-start notification reconnect handling, and /tickets/... routes plus ticket deep links in push payloads (thread links still win when both ids are present). Device registration now opts into notifyOnBlocked.

Server persistence lands consolidated migration 033_WorkflowSchema (former 033–055 DDL, including projection_threads.hidden) and 034_BoardNotifications (workflow_notification_outbox, ticket attention_kind / attention_reason). A sample Standard delivery board is added under .t3/boards/delivery.json.

Orchestration changes when turns settle: assistant messages and diff checkpoints no longer complete a turn while the session is still running; settlement happens when the session leaves running (or a new active turn supersedes). Steering is allowed when a pending turn start matches the provider’s active turn. Stale Codex user-input errors clear pending input in projections. Hidden workflow threads are excluded from public shell snapshots but remain in command read models.

Smaller updates: workflow:read / workflow:operate on OAuth token exchange; auth tests pinned to AuthStandardClientScopes / AuthAdministrativeScopes; Codex serviceTier (legacy fastMode); git remote status via statusDetailsRemote; Electron skips web-only context menu headers; desktop Tailscale DNS read uses Effect.orElseSucceed.

Reviewed by Cursor Bugbot for commit e447e02. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1eeddea3-1682-4e28-b614-3804077a4824

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jun 10, 2026
Comment thread apps/server/src/workflow/Layers/TurnStateReader.ts Outdated
Comment thread apps/server/src/workflow/workflowFile.ts
Comment thread apps/web/src/components/board/TicketCard.tsx Outdated
Comment thread apps/server/src/workflow/Layers/ApprovalGate.ts
Comment thread apps/web/src/components/board/editor/canvas/CanvasView.tsx
@ccdwyer

ccdwyer commented Jun 10, 2026

Copy link
Copy Markdown
Author

Addressed all 6 Macroscope findings in 87db8cc:

  • ApprovalGate race (Medium): getOrCreate now registers the deferred atomically via Ref.modify — concurrent callers always share one deferred.
  • TurnStateReader interrupted (Medium): completed now includes interrupted, matching toTurnState's terminal classification.
  • workflowFile status path (Low): steps.<key>.status.<extra> is now rejected, same as exitCode.
  • TicketCard failed badge (Low): label fixed to "failed".
  • CanvasView record equality (Low): key presence checked explicitly instead of ?? 0 masking.
  • VersionHistoryPanel race (Low): stale async version loads are invalidated by a request counter.

Same commit also hardens review-panel verdict capture (found during the live demo run): captured output now falls back to earlier assistant messages in the turn when the final message lacks the fenced json block, and the auto-appended captureOutput suffix explicitly overrides skill-driven output formats.

🤖 Generated with Claude Code

@ccdwyer ccdwyer marked this pull request as ready for review June 10, 2026 21:44
Comment thread apps/server/src/ws.ts
Comment thread apps/server/src/terminal/Layers/Manager.ts
@macroscopeapp

macroscopeapp Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

3 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

Comment thread apps/server/src/workflow/Layers/WorkflowRecovery.ts
) {
return yield* eventStoreError(
`branch diverged: ${firstLine(push.stderr) || firstLine(push.stdout) || "remote push rejected"}`,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Push errors mislabeled diverged

Medium Severity

In openPr, any failed push whose combined stdout/stderr contains the substring rejected is turned into a branch diverged error. Git uses [remote rejected] for many non-divergence failures (permissions, protected branches, hook rejects), so those cases surface as a blocked “branch diverged” outcome instead of a distinct push failure.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b81ddaa. Configure here.


const looksNotMergeable = (text: string): boolean => {
const lower = text.toLowerCase();
return NOT_MERGEABLE_PATTERNS.some((pattern) => lower.includes(pattern));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge errors match checks substring

Medium Severity

looksNotMergeable treats any GitHubCliError detail containing the substring checks as a not-mergeable PR outcome. Unrelated failures whose message mentions “checks” (for example API or CLI errors while loading check status) are converted to { ok: false } on mergePr instead of propagating as infrastructure errors on the error channel.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b81ddaa. Configure here.

if (!result.ok) {
return blocked(result.reason);
}
return { _tag: "completed" };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Land leaves PR state stale

Medium Severity

After a successful land merge via mergePr, the service returns completed but does not emit a workflow event or update workflow_pr_state. The row stays open with old CI/review fields until the GitHub poller runs, so getTicketPrState, ticket pr views, and pr.* external-event predicates can stay wrong for up to a poll interval after the step succeeds.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b81ddaa. Configure here.

Comment thread apps/server/src/workflow/redactSensitiveText.ts
Squashed feature branch: per-project workflow boards as state machines that
drive agents, and the GitHub PR loop that closes the ship-it cycle.

Boards: lanes hold pipelines of agent / approval / script / merge / pullRequest
steps; one git worktree per ticket; smart routing via JSON-logic predicates,
captured step output, lane transitions and external events; WIP limits with a
FIFO queue; ticket dependencies; token budgets; review panels with majority
verdicts; retry/escalation; durable approvals and crash recovery. Visual editor
(form + canvas), version history/revert, board digest, ticket aging, dry-run
simulator, webhooks, and a delete-board cascade.

GitHub PR loop: a pullRequest step opens/lands PRs via gh; a background poller
turns PR/CI/review state into the external events boards already route on
(ci.passed/failed, pr.approved/changes_requested/merged/closed) through a
two-phase durable observation outbox; reviewer comments and redacted CI logs
sync into the ticket discussion; compound gates read pr.ciState/pr.reviewDecision.

Persistence: all workflow schema lands in a single migration (033_WorkflowSchema).
Comment on lines +843 to +846
if (renamed) {
setIsRenaming(false);
renameInputRef.current = null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/Sidebar.tsx:843

When renameBoardForProjectMember returns false, renameCommittedRef.current stays true but isRenaming remains true. This causes handleRenameInputBlur to skip calling cancelRename() because it checks !renameCommittedRef.current, so clicking away from the input does nothing. The user is trapped in rename mode until they press Escape. Reset renameCommittedRef.current to false when the API returns false to restore blur-to-cancel behavior.

-      if (renamed) {
-        setIsRenaming(false);
-        renameInputRef.current = null;
+      if (renamed) {
+        setIsRenaming(false);
+        renameInputRef.current = null;
+      } else {
+        renameCommittedRef.current = false;
       }
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/Sidebar.tsx around lines 843-846:

When `renameBoardForProjectMember` returns `false`, `renameCommittedRef.current` stays `true` but `isRenaming` remains `true`. This causes `handleRenameInputBlur` to skip calling `cancelRename()` because it checks `!renameCommittedRef.current`, so clicking away from the input does nothing. The user is trapped in rename mode until they press Escape. Reset `renameCommittedRef.current` to `false` when the API returns `false` to restore blur-to-cancel behavior.

Evidence trail:
apps/web/src/components/Sidebar.tsx lines 800-804 (cancelRename sets renameCommittedRef.current=true), lines 806-814 (startRename sets renameCommittedRef.current=false, isRenaming=true), lines 854-866 (handleRenameInputKeyDown sets renameCommittedRef.current=true on Enter, then calls commitRename), lines 838-849 (commitRename: if renamed is false, isRenaming is never set to false, renameCommittedRef.current remains true), lines 868-872 (handleRenameInputBlur: if renameCommittedRef.current is true, cancelRename is not called).

pipelineStepCount: Schema.Number,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low src/workflow.ts:760

pipelineStepCount uses Schema.Number which accepts NaN, Infinity, and non-integer values like 1.5. Since this represents a count of pipeline steps, it should be Schema.Int (or NonNegativeInt) to match the adjacent wipLimit field.

-        pipelineStepCount: Schema.Number,
+        pipelineStepCount: Schema.Int,
🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/contracts/src/workflow.ts around line 760:

`pipelineStepCount` uses `Schema.Number` which accepts `NaN`, `Infinity`, and non-integer values like `1.5`. Since this represents a count of pipeline steps, it should be `Schema.Int` (or `NonNegativeInt`) to match the adjacent `wipLimit` field.

Evidence trail:
packages/contracts/src/workflow.ts lines 750-769 at REVIEWED_COMMIT — `pipelineStepCount: Schema.Number` (line 760) vs `wipLimit: Schema.optional(Schema.Int)` (line 761). Effect Schema docs at https://effect.website/docs/schema/json-schema/ confirm Schema.Int 'Ensures that the provided value is an integer number (excluding NaN, +Infinity, and -Infinity)' while Schema.Number accepts any JavaScript number.

Comment on lines +49 to +65
park: (stepRunId) => getOrCreate(stepRunId).pipe(Effect.asVoid),
await: (stepRunId) =>
Effect.gen(function* () {
const id = stepRunId as string;
const deferred = yield* getOrCreate(id);
yield* incrementWaiter(id);
return yield* Deferred.await(deferred).pipe(Effect.ensuring(decrementWaiter(id)));
}),
resolve: (stepRunId, approved) =>
Effect.gen(function* () {
const id = stepRunId as string;
const deferred = yield* getOrCreate(id);
const liveWaiters = (yield* Ref.get(activeWaiters)).get(id) ?? 0;
yield* Deferred.succeed(deferred, approved);
return liveWaiters > 0;
}),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/ApprovalGate.ts:49

In await, interruption between incrementWaiter and Deferred.await leaves the waiter count permanently elevated. The Effect.ensuring(decrementWaiter) only runs once Deferred.await begins, so if the fiber is interrupted after incrementWaiter completes but before the pipeline starts, decrementWaiter never runs. This causes activeWaiters to accumulate stale counts and resolve to return incorrect values. Consider wrapping the entire incrementWaiter + Deferred.await sequence in Effect.ensuring, or using Effect.acquireUseRelease to bracket the increment with the decrement.

      park: (stepRunId) => getOrCreate(stepRunId).pipe(Effect.asVoid),
-      await: (stepRunId) =>
-        Effect.gen(function* () {
-          const id = stepRunId as string;
-          const deferred = yield* getOrCreate(id);
-          yield* incrementWaiter(id);
-          return yield* Deferred.await(deferred).pipe(Effect.ensuring(decrementWaiter(id)));
-        }),
+      await: (stepRunId) =>
+        Effect.gen(function* () {
+          const id = stepRunId as string;
+          const deferred = yield* getOrCreate(id);
+          yield* incrementWaiter(id);
+          return yield* Deferred.await(deferred);
+        }).pipe(Effect.ensuring(decrementWaiter(stepRunId as string))),
      resolve: (stepRunId, approved) =>
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/workflow/Layers/ApprovalGate.ts around lines 49-65:

In `await`, interruption between `incrementWaiter` and `Deferred.await` leaves the waiter count permanently elevated. The `Effect.ensuring(decrementWaiter)` only runs once `Deferred.await` begins, so if the fiber is interrupted after `incrementWaiter` completes but before the pipeline starts, `decrementWaiter` never runs. This causes `activeWaiters` to accumulate stale counts and `resolve` to return incorrect values. Consider wrapping the entire `incrementWaiter` + `Deferred.await` sequence in `Effect.ensuring`, or using `Effect.acquireUseRelease` to bracket the increment with the decrement.

Evidence trail:
apps/server/src/workflow/Layers/ApprovalGate.ts lines 48-65 at REVIEWED_COMMIT: the `await` method at line 50-56 performs `yield* incrementWaiter(id)` (line 54) then `yield* Deferred.await(deferred).pipe(Effect.ensuring(decrementWaiter(id)))` (line 55). The `Effect.ensuring` finalizer is scoped only to the `Deferred.await` pipeline, not to the increment. The `resolve` method at lines 57-64 reads `activeWaiters` at line 61 and returns `liveWaiters > 0` at line 63.

blockedBy: new Set(readStringArray(task.blockedBy)),
});
}
return tasks.size > 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low Layers/ClaudeAdapter.ts:791

When TaskList returns an empty array, tasks.clear() empties the map but the function returns false because tasks.size > 0 is false. This incorrectly signals no change occurred, even though all tasks were removed. If callers use the return value to trigger updates, the UI won't reflect that tasks were cleared.

-    return tasks.size > 0;
+    return true;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ClaudeAdapter.ts around line 791:

When `TaskList` returns an empty array, `tasks.clear()` empties the map but the function returns `false` because `tasks.size > 0` is false. This incorrectly signals no change occurred, even though all tasks were removed. If callers use the return value to trigger updates, the UI won't reflect that tasks were cleared.

Evidence trail:
apps/server/src/provider/Layers/ClaudeAdapter.ts lines 768-791 (REVIEWED_COMMIT): `TaskList` branch calls `tasks.clear()` unconditionally then returns `tasks.size > 0`. apps/server/src/provider/Layers/ClaudeAdapter.ts lines 2464-2471 (REVIEWED_COMMIT): caller uses the return value to decide whether to emit `emitClaudeTaskPlanUpdated`.

ccdwyer added 16 commits June 13, 2026 02:52
…ttention columns

Adds workflow_notification_outbox table with UNIQUE sequence constraint,
idx_workflow_notification_outbox_pending index, and attention_kind/attention_reason
columns on projection_ticket. Updates 033 golden test to scope its schema
equivalence gate away from new 034 objects.
Populate attention_kind and attention_reason on projection_ticket when
tickets enter a needs-you state (StepAwaitingUser, TicketBlocked) and
clear both fields on every other projection_ticket status setter
(TicketMovedToLane, TicketQueued, TicketAdmitted, PipelineStarted,
StepUserResolved) so stale values never linger on running/idle tickets.
…w, listNeedsAttentionTickets query + outbox delete cascade

Detail view now carries projected attention_kind/attention_reason and a
currentLane { key, name, actions } resolved from the board definition via
BoardRegistry (key-only fallback when the definition is unregistered).

Adds listNeedsAttentionTickets — one INNER JOIN over projection_ticket /
projection_board returning waiting_on_user|blocked tickets with board name,
oldest-touched first. The WS connection is environment-scoped so no env filter
is needed.

Cascades workflow_notification_outbox deletion in both deleteTicketState and
deleteBoardTicketState. WorkflowReadModel now depends on BoardRegistry, sealed
in WorkflowFoundationLive.
ccdwyer added 18 commits June 13, 2026 04:55
Make ApnsNotificationPayload support both thread and ticket notifications.
threadId is now optional; boardId and ticketId fields are added (also optional).
ApnsClient writes only whichever identity fields are present in the payload.
The thread notification path is byte-identical to before (regression-tested).

Fix two call sites in ApnsDeliveries.ts where notification.threadId
(now string|undefined) was passed to APIs expecting string|null.
Adds notifyOnBlocked to RelayClientDeviceRecord.notifications (required
boolean) and wires it through Devices.listForUser with a ?? true default
so rows stored before this field existed decode correctly. Tests cover
both the absent-field default and the explicit-false case.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e447e02. Configure here.

if (typeof environmentId === "string" && typeof threadId === "string") {
return encodeThreadDeepLink({ environmentId, threadId });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty threadId blocks ticket links

Medium Severity

When notification payload data includes an empty threadId string together with valid ticket identity fields, extractAgentNotificationDeepLink takes the thread branch, encodeThreadDeepLink returns null, and the function exits without trying the new ticket fallback—so the tap navigates nowhere.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e447e02. Configure here.

Comment on lines +152 to +165
}).pipe(
Effect.catchCause((cause) =>
// Re-raise defects (programming bugs) so the sweep-level catchDefect
// guard surfaces them; only swallow expected/transient failures as a
// per-row "failed" so one bad row can't abort the whole sweep.
// Re-dying with the squashed cause keeps the error channel `never`.
Cause.hasDies(cause)
? Effect.die(Cause.squash(cause))
: Effect.logWarning("workflow.board-notification.row-failed", {
outboxId: row.outboxId,
ticketId: row.ticketId,
cause,
}).pipe(Effect.as("failed" as const)),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/WorkflowBoardNotificationDispatcher.ts:152

The catchCause handler only re-raises dies but swallows interrupts. When a fiber is interrupted during graceful shutdown, Cause.hasDies(cause) returns false, so the interrupt is caught, logged as "row-failed", and processing continues to the next row. This delays shutdown and causes unnecessary work. Consider also checking Cause.hasInterrupts(cause) and re-raising interrupts, or use Effect.catchAll to only catch typed errors.

-      ).pipe(
-        Effect.catchCause((cause) =>
-          // Re-raise defects (programming bugs) so the sweep-level catchDefect
-          // guard surfaces them; only swallow expected/transient failures as a
-          // per-row "failed" so one bad row can't abort the whole sweep.
-          // Re-dying with the squashed cause keeps the error channel `never`.
-          Cause.hasDies(cause)
-            ? Effect.die(Cause.squash(cause))
-            : Effect.logWarning("workflow.board-notification.row-failed", {
-                outboxId: row.outboxId,
-                ticketId: row.ticketId,
-                cause,
-              }).pipe(Effect.as("failed" as const)),
-        ),
-      );
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.ts around lines 152-165:

The `catchCause` handler only re-raises dies but swallows interrupts. When a fiber is interrupted during graceful shutdown, `Cause.hasDies(cause)` returns false, so the interrupt is caught, logged as "row-failed", and processing continues to the next row. This delays shutdown and causes unnecessary work. Consider also checking `Cause.hasInterrupts(cause)` and re-raising interrupts, or use `Effect.catchAll` to only catch typed errors.

Evidence trail:
- apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.ts lines 152-166 (catchCause handler)
- Effect-TS/effect-smol packages/effect/src/internal/effect.ts:171 (`hasDies` checks `isDieReason` only)
- Effect-TS/effect-smol packages/effect/src/internal/effect.ts:186 (`hasInterrupts` exists, checks `isInterruptReason`)
- Effect-TS/effect-smol packages/effect/src/internal/effect.ts:564-581 (`interruptUnsafe` raises interrupt as `failCause`)
- Effect-TS/effect-smol packages/effect/src/internal/effect.ts:2360-2384 (`catchCause` implementation via `OnFailureProto` with `contE`)
- Effect-TS/effect-smol packages/effect/src/internal/effect.ts:4180-4184 (`SetInterruptible.contAll` re-raises `_interruptedCause`)
- apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.ts lines 204-209 (for loop continues after catchCause swallows interrupt)
- pnpm-workspace.yaml:19 (effect version: 4.0.0-beta.73)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant