Skip to content

[Refactor] Rebuild the work-execution data model: tasks, runs, and a first-class initiator - #45

Merged
mrubens merged 25 commits into
developfrom
data-model-simplification
Jul 10, 2026
Merged

[Refactor] Rebuild the work-execution data model: tasks, runs, and a first-class initiator#45
mrubens merged 25 commits into
developfrom
data-model-simplification

Conversation

@mrubens

@mrubens mrubens commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Rebuilds the work-execution data model around six concepts, each answering exactly one question. There are no production users in this iteration, so this is a clean break: no compatibility shims, no dual writes, and migrations collapse to a fresh baseline 0000.

Design doc with full rationale, audit evidence, and per-flow risk analysis: internal artifact (ask Matt).

The model

Concept Table Answers
Task tasks What unit of work exists, who initiated it, where it lives
Run task_runs (was cloud_jobs) One sandbox execution attempt
Initiator 5 columns on tasks, CHECK-enforced Which user or automation caused the task
Automation automations What non-human initiators exist and how they are configured
Work item work_items Pending work that may become a task
Tracked message tracked_messages Outbound chat messages carrying lifecycle state

Highlights

  • Attribution is a single-row read. initiator_kind + initiator_user_id + initiator_automation + external actor context, written once at enqueue from an explicit discriminated union at every launch site. The 45 attribution columns, the feature-flagged effectiveAuthor* blocks, resolveTaskAttributionDisplay (732 lines), and the analytics re-implementation of its cascade are deleted. Analytics now shows automations as first-class creator series.
  • Owner is deleted, not made nullable. Authz is deployment membership (fixes automation tasks being invisible to search and un-editable). Runtime credentials use task_runs.acting_user_id or a deployment service principal in job tokens (r.u claim optional). resolveUserIdForCloudJob, which silently made the first user in the users table the credential owner for all automation work, is gone along with all six placeholder-owner forgeries.
  • Resume is a new run row. Conversation state (channel bindings, title, prompts, PR linkage) lives on tasks/task_pull_requests, so the four chain-walkers and cross-run field copying are deleted. CloudTaskType splits into tasks.workflow x surface x trigger + runs.kind, with payloadKind kept for runtime dispatch only.
  • PR rows are written at enqueue for review/conflict workflows; dedup queries join tasks x task_pull_requests instead of reading job columns.
  • One automations table (key as the only identifier, including BullMQ job names). backgroundAutomationRuns is deleted: run history for task-launching automations is tasks WHERE initiator_automation = key, and manual Run-now is synchronous, returning the launched task id. The ~600-line legacy settings merge, the manager-channel triple fallback, and the coach automation are deleted.
  • One pending-work table and one tracked-message registry replace six tables with three duplicated launch state machines; web-launched suggestions now record their launched task (previously dropped).

Verification

Every stage landed with the full pnpm check green (lint, types, ~5,600 tests across 24 packages, knip). Baseline migration verified against a scratch database; db:generate produces no diff.

End-to-end on a fresh database via the web UI (GitLab-only deployment, Local Docker sandboxes, mock Slack harness): setup onboarding, web task launch through completion, synchronous automation Run-now producing a hidden suggester scan, work items + tracked messages + Slack posts, task history with initiator display, and analytics with user and automation series.

The E2E run also surfaced and fixed four pre-existing GitHub-only bugs that leftover GitHub state in dev databases had been masking (provider stamping at launch, git-proxy gzip double-decode on gitlab.com clones, a setup guard requiring a GitHub installation forever, Microsoft avatar hosts) and three refactor regressions (service-principal tokens rejected by deployment-scoped reads, suggestion posts skipped without a human poster, dequeue-time cancels leaving tasks active).

Follow-ups (non-blocking)

  • Optionally fold the slim background_agent_settings survivors into deployment_settings.
  • Optional TS vocabulary pass renaming CloudJob/CloudTask* types to Run/Task* (tables are already renamed; type aliases marked TODO(stage5-rename)).
  • Manager-stats analytics can now distinguish reviewed-vs-authored PRs since PR rows exist at enqueue.

Internal guidance (.agent-guidance/) and public docs are updated in the same change.

roomote added 8 commits July 8, 2026 22:55
…rompts

The ROOMOTE_APP_URL deploy-screen prompt is now configured on both
published templates (verified on the live deploy screens). Corrections
from doing it: Railway does have a template Duplicate action, and the
prompt surfaces inside the api service's Configure step rather than as a
standalone top-level input.
…y spec

Both published templates already ship these; the spec and operator guide
now match. SETUP_TOKEN is not optional anymore: the app refuses tokenless
first-admin bootstrap outside local development, so the template must
generate it. ROOMOTE_PING_BASE_URL points anonymous-analytics and
version-check pings at the openmote ping service.
Stage 1 of the data-model simplification (see design doc).

- Job tokens: user claim (r.u) now optional; absent = deployment service
  principal. JobTokenContext carries principal: 'user' | 'deployment' and a
  nullable userId.
- Delete resolveUserIdForCloudJob and every first-user/placeholder-owner
  forge: scheduled scans (suggester, triage runners, merged-PR auditors,
  CI-failure webhook) now enqueue with userId null as explicit automation
  launches.
- enqueueCloudTask: identity-less launches allowed for automation/maintenance
  launch classes (interim until the Stage 2 initiator union).
- Token/job principal match: (cloudJob.userId ?? null) === token.userId;
  null == null is the valid automation case.
- MCP proxies: deployment-principal jobs use deployment-scoped mcp_connections
  rows; human-required MCPs 403 naming the service principal; no more borrowed
  human credentials.
- Thread nullability honestly through sdk/api/web/worker; update tests that
  asserted forged-owner behavior to the new principal semantics.
…ator stamp

Stage 2 of the data-model simplification (see design doc).

- tasks is now the durable unit of work: absorbs conversation cargo from
  cloud_jobs (channel bindings, title, prompts, requestedWorkKind,
  harnessInstructions, draftPrompt), gains workflow x surface x trigger x
  visibility x state classification, soft delete, and the immutable
  five-column initiator stamp (CHECK-enforced: user or automation, external
  actor context preserved).
- cloud_jobs becomes task_runs: one sandbox execution attempt with a real FK
  to tasks, kind fresh|resume, sourceRunId, actingUserId as the only user
  column, payloadKind for runtime dispatch. Resume is a new run row; the four
  chain-walkers and cross-run field copying are gone.
- 45 attribution columns collapse to 11: the initiator stamp plus a 5-column
  commit-author block (with an 'external' kind so unlinked GitHub authors
  keep noreply-email commit identity), evaluated unconditionally at enqueue.
- enqueueCloudTask takes an explicit initiator union; all 47 call sites pass
  their real initiator (webhook reviews = automation with actor context,
  mentions = the human, scans = the automation, resumes never re-attribute).
  attributionOverride, inferSourceKind payload sniffing, resumePromptUserId,
  and the 732-line display cascade are deleted.
- PR linkage writes at enqueue into extended task_pull_requests; PR-review
  dedup queries move to tasks JOIN task_pull_requests; Redis queue scope is
  explicit.
- Read paths: task list/search/analytics/filters/manager-stats are
  single-table initiator reads; visibility is a column; task type filters use
  workflow; owner-scoped authz becomes deployment membership (fixes
  automation tasks being invisible/un-editable); deletion is soft delete.
- CloudTaskType, deletedTasks, evalRuns, usage-table userId copies, and
  task-attribution.ts deleted. Satellites re-keyed to runId with real FKs.
…d legacy settings

Stage 3 of the data-model simplification (see design doc).

- One automations table (key text PK, targets as jsonb) replaces
  backgroundAutomations + backgroundAutomationTargets; rows seeded
  idempotently; tasks.initiatorAutomation is now a real FK to it.
- backgroundAutomationRuns deleted: run history for task-launching
  automations is tasks WHERE initiator_automation = key; announcer and
  manager-stats history comes from the automations row's last* columns;
  dispatch failures land in lastError.
- Manual Run-now is synchronous: the web tRPC command invokes the shared
  runner directly and returns the launched task id (or skip/error reason);
  apps/web's direct BullMQ producer coupling is deleted.
- backgroundAgentSettings slimmed to genuinely global survivors; the ~20
  write-dead persona columns, the ~1,250-line legacy normalize/merge layer,
  and the manager-channel triple fallback are deleted. Channel resolution is
  two levels: automation target, then manager channel.
- One canonical snake_case automation key everywhere, including BullMQ job
  names (one-time scheduler cleanup for 12 retired names); the four-identifier
  regex conversion layers are deleted; coach is deleted end to end.
- Runners relocated to packages/sdk/src/server/automations and shared by the
  scheduler and the synchronous trigger path.
- Stage 2 follow-up included: worker consumes DequeuedTaskContext
  (requestedWorkKind + channel bindings) from dequeue/resume responses, and
  .agent-guidance docs updated for the Stage 1-2 model.
Stage 4 of the data-model simplification (see design doc).

- work_items replaces taskSuggestions + automationWorkItems +
  setupNewQueuedTasks: one shape with a kind discriminator and ONE launch
  state machine (open/launching/launched/failed/dismissed, stale-claim
  recovery preserved). Every launchable surface now records launchedTaskId,
  fixing the dropped suggestion-to-task link for web launches. MCP
  recommendations get real work_items rows.
- tracked_messages replaces agentSuggestionMessages +
  backgroundAutomationSlackThreads + mcpSetupManagerNotifications: a pure
  outbound-message registry with UNIQUE(kind, dedupeKey); launch state lives
  on work_items; the reaction-launch claim CAS moves to work_items.
- fastAgentSessions renamed slack_quick_answers, deliberately outside the
  task spine; the automation-thread feedback loop keeps working against
  tracked_messages.
…ead-code sweep

Stage 5 of the data-model simplification (see design doc).

- All prior migrations collapse into a single baseline 0000 generated from
  the rebuilt schema; fresh-database apply verified on a scratch DB; schema
  and baseline are in sync (db:generate produces no diff).
- 20 .agent-guidance docs updated to the new model (spine, automations,
  work_items, tracked_messages, queue naming); public docs corrected where
  the old forged-owner behavior was described.
- Dead mocks and stale references removed; knip clean; full pnpm check green.
Found by driving a fresh database through setup, task launch, automations,
history, and analytics in the browser. Four pre-existing bugs (masked until
now by leftover GitHub state in dev databases) plus three refactor
regressions:

- Stamp payload.sourceControlProvider once at enqueue from the workspace's
  synced repositories (plus dequeue-time fallback and explicit stamps at the
  setup/onboarding launch sites). Non-GitHub deployments previously fell back
  to the GitHub default and failed source-control token creation or repo
  resolution. (pre-existing)
- Worker git proxy: stop forwarding content-encoding/content-length/
  transfer-encoding; fetch already decodes bodies, so gzip-serving hosts like
  gitlab.com produced 'incorrect header check' clone failures. (pre-existing)
- Setup guard no longer requires a GitHub installation after setup completes;
  GitLab/Gitea/ADO-only deployments were bounced back to /setup forever.
  (pre-existing)
- Allow graph.microsoft.com avatars in next/image (Teams sign-in). (pre-existing)
- Deployment-scoped reads (repositories, MCP enablement) accept
  deployment-service-principal job tokens; automation runs failed workspace
  prep with 'Invalid authorization token'. (regression)
- Suggestion posting no longer requires a human poster: automation scans with
  no user now post to Slack/Telegram/Teams, and suggestion work_items carry
  the initiating automationKey. (regression)
- Every dequeue/enqueue-time cancel path now writes tasks.state='canceled',
  matching finishCloudJob; canceled-before-start tasks no longer stay active
  forever. (regression)
- Suggester eligibility is provider-agnostic (any active repository) instead
  of requiring a GitHub App installation. (pre-existing)
@roomote-roomote-v1

roomote-roomote-v1 Bot commented Jul 9, 2026

Copy link
Copy Markdown

No new code issues found. See task

  • packages/cloud-agents/src/server/cloud-job-queue.ts:1443 Snapshot resume creates a new task_runs row but never sets the owning tasks.state back to active; because task history and summaries now read tasks.state, a resumed task can remain classified as completed while the new run is queued or running.
  • apps/api/src/handlers/tasks/cancelTask.ts:49 and apps/api/src/handlers/tasks/task-stop.ts:55 These direct cancel paths update only task_runs.status; they bypass the shared cancellation helpers that also write tasks.state, so queued or booting tasks canceled through MCP/Slack/Telegram can stay task-level active forever.
  • packages/db/src/lib/sync-task-state.ts:112 syncTaskStateFromRuns now serializes sibling-run state derivation by locking the owning tasks row inside the same production transaction that wrote the run-status change, so concurrent finishers cannot both derive from stale sibling state.
  • apps/api/src/handlers/slack/events/reactions.ts:497, apps/api/src/handlers/telegram/callback-actions.ts:223, apps/web/src/trpc/commands/task-suggestions/implement.ts:131, and apps/api/src/handlers/tasks/automation-work-items/launch.ts:295 The stale-launch fencing fixes now best-effort cancel orphaned runs on Slack/Telegram/web/Teams and fence the automation failure writes, so stale launchers no longer leave unlinked runs active or clear/fail a fresh claimant's row after reclaim.
  • apps/api/src/handlers/slack/events/reactions.ts:552, apps/web/src/trpc/commands/task-suggestions/implement.ts:132, apps/api/src/handlers/teams/suggestion-start.ts:253, and apps/api/src/handlers/telegram/callback-actions.ts:234 Lost-finalize launchers no longer report the canceled orphan as the successful launch: Slack removes its seeded duplicate thread, web throws the already-implemented outcome, Teams returns already_started with a corrective reply, and Telegram posts a corrective reply after canceling the orphan.
  • apps/web/src/trpc/commands/setup-new/index.ts:1001 The setup-new onboarding queue now uses fenced work-item claims, but if finalizeWorkItemLaunched returns false after enqueueCloudTask succeeds, this branch only logs and leaves the new task/run executing unlinked. Other launch surfaces best-effort call cancelTaskRunDirect for the orphaned launchResult.id; without the same cancel here, a stale/reclaimed setup launcher can still start duplicate onboarding work that no work item owns.
  • apps/web/src/components/settings/ComputeProviderSection.tsx:419 The missing-worker-image warning is rendered only when advancedInfraFields.length > 0, but this change removes E2B_TEMPLATE_ID and DAYTONA_SNAPSHOT_NAME from that operator-editable advanced list. If the remaining optional infra fields are all runtime-satisfied while the worker artifact is still unsatisfied, Settings hides the only guidance telling the operator to configure a registry-qualified DOCKER_WORKER_IMAGE, even though the provider cannot be selected as default. — dismissed: this file is no longer part of PR [Refactor] Rebuild the work-execution data model: tasks, runs, and a first-class initiator #45's current base-to-head diff after the base-branch merge, so it is not introduced by this pull request.

…item claim lifecycle, delivery fallback, provider resolver

Fixes the 15 findings from the max-effort review of this PR:

- One shared task-state derivation (syncTaskStateFromRuns): any non-terminal
  run keeps the task active; otherwise the latest run that made progress wins.
  All six writers route through it (finishCloudJob, both dequeue-time cancels,
  queue supersede-eviction, enqueueSnapshotResume, sleep-check non-resumable
  shutdown, snapshot completion, and both user-facing direct cancels). Fixes:
  tasks stuck active after non-resumable shutdown or direct cancel; live
  resumes reported completed; failed-bootstrap resumes clobbering completed
  tasks; last-finisher-wins across siblings. The supersede-eviction also gains
  a status guard and skips when the Redis eviction lost the race.
- work_items unique index now includes kind (regenerated baseline migration),
  fixing the guaranteed onboarding-queue collision with suggestion rows. One
  shared claim/release/finalize helper with a single stale window replaces
  the four hand-rolled CAS copies; stale 'launching' rows are recoverable
  everywhere, releases never revert a launched item, and claims are guarded
  on launched_task_id.
- isVisibleTask() now also requires deletedAt IS NULL, closing the API/MCP
  read paths that served soft-deleted tasks; soft delete also removes the
  task's artifact rows in the same transaction.
- Suggestion delivery precedence is now keyed on actual delivery: Slack
  returns a delivered boolean and Telegram/Teams fire only when the prior
  surface did not deliver, instead of self-suppressing on installation
  existence (which silently dropped suggestions).
- One shared workspace provider resolver (repository/set/environment/
  all_repositories) feeds the enqueue stamp, the dequeue fallback, and the
  web helpers, closing the Linear environment/all-repositories hole on
  non-GitHub deployments.
- Automation tasks regain the env-var auto-stop RPC via deployment-principal
  sandbox tokens; manager-stats automation labels use the shared
  formatAutomationLabel.
@mrubens

mrubens commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Max-effort review completed; all 15 findings fixed in 8e714a1

A 10-angle review (line-by-line, removed-behavior, cross-file tracing, language pitfalls, state machines, reuse, simplification, efficiency, altitude, conventions) with adversarial verification surfaced 15 findings (14 confirmed, 1 plausible). All are fixed:

Task-state consistency family (7 findings) — introduced syncTaskStateFromRuns / deriveTaskStateFromRuns in @roomote/db: any non-terminal run (including idle) keeps the task active; otherwise the latest run that made progress determines the terminal state. All writers route through it. This fixes tasks stuck active after non-resumable sleep shutdowns and direct cancels, live resumes reported as completed, failed-bootstrap resumes clobbering completed tasks, last-finisher-wins across sibling runs, and the queue supersede-eviction race (now status-guarded and lrem-count-checked).

Work-items lifecycle (3 findings) — the unique index now includes kind (baseline migration regenerated), fixing a guaranteed onboarding-queue collision. One shared claim/release/finalize helper replaces four hand-rolled CAS copies: stale launching rows are recoverable everywhere, releases never revert a launched item, claims guard on launched_task_id.

Soft-delete integrity (2 findings)isVisibleTask() now also requires deletedAt IS NULL (soft-deleted tasks were readable via API/MCP reads and artifact auth); soft delete removes the task's artifact rows in-transaction.

Delivery + provider + misc (3 findings) — suggestion fallback precedence now keys on actual delivery (Slack returns a delivered boolean) instead of installation existence, so suggestions can no longer silently reach no surface; one shared workspace provider resolver closes the environment/all-repositories stamping hole (e.g. Linear launches on GitLab-only deployments) and replaces three divergent copies; automation tasks regain the env-var auto-stop RPC via deployment-principal sandbox tokens; manager-stats automation labels use the shared formatAutomationLabel.

Full pnpm check (lint, types, all tests, knip) green after the fixes.

roomote and others added 16 commits July 9, 2026 14:53
Resolves conflicts by porting develop's changes onto the new data-model
contract:
- setup-new onboarding kickoff adopts develop's web-only Teams fallback
  (#48) with the new enqueue contract (user initiator, setup_onboarding
  workflow, provider stamp) instead of attributionOverride
- Telegram routing-confirmation launch helper (#22) ported to the new
  contract, keeping launchClass: 'human' for keepalive policy
- snapshot refresh keeps the automation initiator; dropped the re-merged
  forged-owner fallback
- setup redirect guard keeps the provider-agnostic completion check
- drizzle baseline regenerated to fold develop's 0001 (nullable
  environments.created_by_user_id + declarative_source) into the single
  0000 baseline
- develop-added tests updated to the new enqueue/factory shapes
Finding 1: syncTaskStateFromRuns now takes a `SELECT ... FOR UPDATE` row
lock on the owning tasks row before reading the sibling runs, serializing
concurrent sibling-run syncs so two runs finishing at once can no longer
each miss the other's terminal write and leave the task stuck 'active'.
The doc comment records the lock and the must-run-inside-the-writing-
transaction invariant. All call sites already pass a transaction `tx`.

Finding 2: work-item launch claims are now fenced by the claim's
`launchClaimedAt` timestamp. finalizeWorkItemLaunched and
releaseWorkItemClaim require a `claimedAt` token and match it in their
WHERE guard, so a slow launcher whose stale claim was reclaimed cannot
finalize or release onto the new claimant's state. Every caller threads
the claimed row's launchClaimedAt through. When finalize loses the guard
after a task was already enqueued, the caller logs loudly with the work
item id and orphaned task id. Adds fencing test coverage.
The Telegram suggestion button claimed the work item to 'launching' but
never finalized or released it: launched_task_id stayed NULL forever (so
after the 10-minute stale window a second click could relaunch a
suggestion that already produced a task), every launched task ran
unlinked from its work item, and a failed launch left the suggestion
dead for 10 minutes instead of immediately retryable.

claimTelegramSuggestionLaunch now returns the claim's launchClaimedAt
fencing token. The callback handler finalizes with the task id + token
on a successful launch (logging loudly with both ids if the fenced
finalize loses to a reclaim), and releases the claim with the token when
routing replies inline, when a confirmation path defers the launch, or
when the launch throws.

Teams needs no equivalent change: its suggestion surface posts plain
text ("start idea 2") and never claims a work item.
task_pull_requests rows now exist at enqueue with the owning task's
workflow, so a PR joined to a pr_review task is one Roomote reviewed and
everything else is one Roomote authored (authored wins when both rows
exist; bot-authored PRs with no task row count as authored). The digest
gains authoredPullRequests/reviewedPullRequests, the Slack headline shows
the split, and merged-PR stats are now authored-only (a merged PR Roomote
merely reviewed no longer counts as Roomote's merge outcome).
syncTaskStateFromRuns now issues select().for('update'); the mock chain
needs the method or every terminal-status test throws.
Teams posts its suggestion lists as one numbered markdown message and
tells the user to reply "start idea 2", but the reply flowed through the
generic task-entry path: no work-item claim (the same suggestion could
be started twice), no launched_task_id link, and no fenced finalize.

Add a conservative whole-message "start idea N" / "idea N" hook in the
Teams webhook route. Matches resolve N against the newest tracked
suggestion-card group in the conversation (bare conversation-id compare,
work_items sort order = posted numbering) and drive the launch through
the shared work_items claim state machine like Slack and Telegram:
claimWorkItem CAS -> launch -> finalizeWorkItemLaunched with the claim's
launchClaimedAt fencing token (loud warn with work item + orphaned task
ids on a lost finalize), and releaseWorkItemClaim with the token on the
inline-reply and failure paths so the suggestion is retryable
immediately. Claim-CAS losses and out-of-range idea numbers get a
visible reply; conversations with no tracked suggestion cards fall
through to normal task entry unchanged. Runs before the snapshot resume
so a suggestion start is never swallowed as a follow-up to the previous
task.
…ites

Part 1: when a fenced finalizeWorkItemLaunched loses to a reclaim, the
already-enqueued run no longer keeps running unlinked. Extract the
stop-task direct-cancel transaction into a shared @roomote/db helper
(cancelTaskRunDirect: guarded pre-sandbox cancel + syncTaskStateFromRuns
+ parallel-count close; task-stop.ts now delegates to it) and have all
four lost-finalize sites (Slack reactions x2, Telegram callback, Teams
suggestion start, web implement) best-effort cancel the orphaned run by
its enqueue run id. The cancel wrapper never throws; its outcome is
appended to the loud lost-finalize warn either way.

Part 2: the automation launch catch path updated work_items by id only,
so a stale launcher's failure handling could fail or clear a fresh
claimant's row. Both failure writes are now fenced: the terminal-failure
stamp requires status='launching' AND our launchClaimedAt claim token,
and the retry reopen requires status='launched' AND the task link we
just finalized. A non-applying fenced write logs and leaves the current
claimant's row untouched. Audited the other claim surfaces: no other
raw post-claim work_items writes exist (the setup-new onboarding queue
uses a separate non-reclaimable lifecycle, flagged for follow-up).
Ports develop's changes onto the new data model:
- #50's enqueue-time provider inference is dropped in favor of our
  equivalent shared resolveWorkspaceSourceControlProvider (already stamps
  at enqueue and covers all four workspace shapes); its test coverage is
  ported to packages/db/src/lib/__tests__/source-control-provider.test.ts
- #53's cancel-intent tracking lands on the new model: cancelRequestedAt
  on task_runs (stamped by cancelTaskRunDirect and the stop paths),
  failedAfterStopRequest suppression of Slack/Teams/Linear failure
  notifications and canceled GitHub-facing outcomes in finish-cloud-job,
  sleep-check finalizing stop-requested sweeps as Canceled
- #53's new tests (task-stop, sleep-check, finish-cloud-job suppression)
  ported from the old cloudJobs/userId shapes to task_runs/actingUserId
  and the run/task split; develop's two review-summary outcome tests were
  dropped (no scaffolding for that path in our test file — the
  suppression mechanism is covered by the conflict/Slack/Teams/Linear
  cases)
- baseline migration regenerated as a single 0000 (folds develop's 0002)
…aims (#56)

Co-authored-by: Roomote <roomote@roocode.com>
A lost finalizeWorkItemLaunched means this launcher lost the claim and
its just-enqueued run was orphan-canceled, but every surface still
reported the launch as started and pointed the user at the canceled
orphan. Each surface now reports the claim-lose outcome instead, keeping
the existing loud warn and best-effort cancel:

- Slack reactions: both lost-finalize branches (happy path and
  post-enqueue recovery) now mirror the claim-lose path — return
  handled, never post the started thread message, and best-effort
  delete the seeded root message so no dangling thread points at the
  canceled orphan. The started post was already gated behind finalize
  in both branches; the recovery branch no longer falls through to it.
- Web implement: throws the same "already been implemented" error the
  claim-lose path throws instead of returning success with the orphaned
  task ids; the catch-block release is fenced on the stale token so it
  cannot touch the winner's claim.
- Teams suggestion start: returns a new 'already_started' outcome
  (matching the claim-CAS-lose reply semantics) and posts a corrective
  follow-up, since startNewTeamsTask posts its started acknowledgement
  before the finalize.
- Telegram callback: posts a corrective reply to the triggering message
  ("already started elsewhere — duplicate canceled"), since the
  callback was already answered "Starting:" and the started post
  happens inside startNewTelegramTask, whose contract is shared with
  other callers.
…h-logging gaps (#57)

Co-authored-by: Roomote <roomote@roocode.com>
The sandbox-oidc refresh sweep's hand-written grouped SKIP LOCKED claim
still referenced sandbox_oidc_targets.cloud_job_id after the column was
renamed to run_id, so every bullmq refresh tick failed with 42703.
Raw SQL is invisible to TypeScript and the sandbox-oidc tests mock
db.execute, so no gate caught it. The claim is now extracted into
claimDueSandboxOidcTargets with a real-database test that makes Postgres
parse the statement, guarding all its identifiers against future drift.
- #60 (PR review notifications for everyone): kept develop's removal of
  the feature-flag gate, expressed with our task_runs naming
- doc frontmatter and model-recommendation conflicts resolved trivially
finishCloudJob computed failedAfterStopRequest only AFTER the terminal
transaction, so a user-stopped task whose sandbox died mid-cancel was
REPORTED as canceled (notifications suppressed, GitHub outcomes mapped
to canceled) but PERSISTED as failed: the run row kept status=failed
with completedAt stamped, and syncTaskStateFromRuns derived
tasks.state='failed' — the canonical read for task history, analytics,
and unfurls showed a deliberate stop as a failure.

Normalize once at the top instead: an incoming Failed on a run with
cancelRequestedAt set becomes Canceled for everything downstream — the
persisted run status (canceledAt stamped, completedAt cleared, which is
the correct shape for a stop), the derived tasks.state, the lifecycle
event ('Cloud job was canceled.'), notifications, and GitHub-facing
outcomes. The sanitized error is still written to the run's error
column for debugging. This deletes the failedAfterStopRequest flag, its
!flag conditions in the Slack/Teams/Linear/conflict gates, and the
local recomputation in cleanupGithubPrReviewArtifacts.

Behavior notes: the stop-normalized run no longer triggers
refreshTaskTitleOnCompletion (Completed/Failed only), matching how
plain cancels always behaved; analytics only capture task_completed, so
nothing miscounts. sleep-check already normalizes at the caller and is
untouched; the controller's Failed-on-spawn-failure path now correctly
persists canceled when a stop was requested mid-spawn.

Tests: the four suppression tests now also assert the persisted run
write (status canceled, canceledAt set, completedAt null, error kept),
plus a new test that a plain Failed without a stop request still
persists failed and notifies.
@mrubens
mrubens merged commit a61d326 into develop Jul 10, 2026
1 check passed
@mrubens
mrubens deleted the data-model-simplification branch July 10, 2026 04:11
@mrubens
mrubens restored the data-model-simplification branch July 10, 2026 12:09
@mrubens
mrubens deleted the data-model-simplification branch July 10, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants