Skip to content

feat(deployments): 使用 River 实现定时调度 - #219

Open
jh0904 wants to merge 3 commits into
mainfrom
codex/deployment-scheduler-river
Open

feat(deployments): 使用 River 实现定时调度#219
jh0904 wants to merge 3 commits into
mainfrom
codex/deployment-scheduler-river

Conversation

@jh0904

@jh0904 jh0904 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

背景与目标

Deployment API 已经能够保存 schedule 并返回 upcoming_runs_at,但此前没有持久化调度器真正执行这些 occurrence。服务重启、并发实例、任务重试以及 API 写入与调度入队之间的失败,都可能让只依赖进程内定时器的方案失去执行状态。

这个 PR 要解决的是:让 scheduled Deployment 能够可靠地创建 Session 和 Deployment Run,并把调度游标、幂等边界、自动暂停和 webhook 事件纳入同一套可恢复的事务模型。

主要改动

  • 引入 River v0.42.0 作为 PostgreSQL 持久任务队列,注册独立的 deployment_schedules queue 和 scheduled Deployment worker。
  • 使用 River 官方 migrator 管理 River 内部表;应用业务表继续由 Goose migration 管理,main 自动迁移和 cmd/migrate up 都会依次推进两套 migration。
  • 增加五段 POSIX Cron、IANA timezone、DST 和确定性 jitter 处理;upcoming_runs_at 保持名义时刻,不包含 jitter。
  • 为 Deployment 增加 schedule_revisionnext_scheduled_at,以 revision + occurrence 使旧 Job 失效并推进调度游标。
  • create、明确修改 schedule 和 unpause 时,通过 Yourbatis 暴露的同一个 SQL transaction 调用 River InsertTx,保证 Deployment 状态与首个 Job 一起提交或回滚。
  • scheduled worker 原子创建 Session/Deployment Run、推进或暂停 Deployment,并写入 webhook outbox;数据库或进程级失败交给 River 重试。
  • (deployment_uuid, scheduled_at) 的部分唯一索引作为 River at-least-once 投递下的最终幂等边界。
  • 补齐 scheduled Run 的 trigger_context、Deployment/Deployment Run webhook、Agent 归档级联 Deployment,以及前端 Run 列表展示。
  • 增加 Cron、Mapper、迁移、事务入队、cursor 并发、outbox 回滚和 API 合同测试。

为什么引入 River

这里需要的不是单纯的 Cron 计算,而是持久化执行生命周期:Job 必须跨进程重启保留,失败可以重试,多实例可以竞争消费,并且首次入队需要和业务状态共用 PostgreSQL 事务。River 提供了这些能力,同时允许应用保留自己的调度游标和最终幂等约束。

当前没有把 Deployment 直接建模为 River 周期任务。每个名义 occurrence 对应一个带 schedule_revision + scheduled_at 的一次性 Job;Deployment 表是 schedule 状态的事实来源,River 负责可靠投递,应用唯一索引负责最终去重。这样 pause、unpause、修改 schedule 和 archive 都可以通过游标和 revision 原子仲裁。

flowchart LR
    API["Create / PATCH / Unpause API"] --> TX["Yourbatis transaction"]
    TX --> DEP["Deployment<br/>revision + cursor"]
    TX -->|"River InsertTx"| JOB["River Job"]
    JOB -->|"scheduled_at + deterministic jitter"| WORKER["Scheduled worker"]
    WORKER --> CHECK["校验 active / revision / cursor"]
    CHECK --> OCC["Occurrence transaction"]
    OCC --> SESSION["Session"]
    OCC --> RUN["Deployment Run"]
    OCC --> OUTBOX["Webhook outbox"]
    OCC --> NEXT["推进 cursor / 自动暂停"]
    NEXT -->|"30s reconcile"| JOB
Loading

worker 完成后不立即插入下一跳,而由每 30 秒运行一次的 reconciliation 根据持久化 cursor 补齐 Job。这是有意选择的最终调度模型:不会丢失 occurrence,重启后也能恢复,但高频 Cron 可能增加几秒到几十秒延迟。如果产品后续要求秒级贴近名义时刻,再单独评估成功事务内直接 enqueue 下一跳。

事务、失败与幂等设计

  • API 写入与首次 River Job 使用同一个数据库事务,任一侧失败都会整体回滚。
  • Job 使用 River ByArgs 去重;参数包含 workspace、Deployment、schedule revision 和名义 scheduled time。
  • worker 提交前以 schedule_revision + next_scheduled_at 仲裁过期或重复 Job。
  • scheduled Run 的唯一索引是最终防线,避免 at-least-once 投递生成重复 Run。
  • Session、Run、Deployment 状态、cursor 和 webhook outbox 在一个 occurrence transaction 内提交。
  • 确定性的配置/引用失败会记录失败 Run;合同定义的错误类型可自动暂停。数据库和进程级错误不提前落最终 Run,交给 River 重试。
  • 根 Agent 归档会在同一事务归档其 Deployment 并写入 deployment.archived outbox。

文档与合同对齐

本 PR 更新了 docs/design/be/deployments-api-contract.md,记录 River migration、Cron/DST/jitter、cursor/revision、reconciliation、scheduled Run、自动暂停和 webhook 事务语义。

实现同时对齐或参考:

验证

  • just test(包含 PostgreSQL 集成测试)
  • just lint
  • just dead-code
  • just complexity
  • just duplicates
  • just large-files
  • just hooks-run
  • just web-format-check
  • just web-lint-naming
  • just web-test(416 tests passed)
  • just web-build
  • git diff --cached --check
  • git diff --check

Review focus / 已知待确认项

这是 Draft PR,当前希望同事重点确认以下边界,再决定是否 Ready for merge:

  1. worker 在 occurrence transaction 之前读取执行配置,而未携带 schedule 的 PATCH 可以修改 agent、environment、events、resources 或 vaults,却不会增加 schedule_revision。需要确认是否应让调度 token 覆盖全部执行输入,避免并发 PATCH 时 Session 使用旧配置、Run 对应新 Deployment 的快照竞态。
  2. robfig/cron 对永远不会发生的表达式可能返回零时间,例如 0 0 31 2 *;当前需要补 IsZero() 防守。timezone 缺失/空字符串以及 Sunday 7 带非法 step 的验证也请一并确认。
  3. 现有测试覆盖了 enqueue transaction、cursor 并发和 outbox 回滚等片段,但还没有直接跑通 scheduledDeploymentWorker.Work() 的完整成功路径。建议至少补一个 worker 集成测试;是否要求完整的 HTTP → River → Work 链路可由 review 决定。
  4. 下一跳依赖 30 秒 reconciliation 是文档化的设计取舍,不会丢触发或双跑,但会带来有界延迟。请确认当前产品对高频 Cron 的精度预期是否接受这一行为。

Summary by CodeRabbit

  • New Features

    • Added scheduled deployments using cron expressions, time zones, daylight-saving support, and bounded jitter.
    • Scheduled runs now execute automatically, retry eligible failures, advance schedules, and may pause after configured failures.
    • Added scheduling limits and safeguards against stale or duplicate runs.
    • Added deployment lifecycle and run-status webhook events.
    • Deployment and agent archival workflows now emit related webhook notifications.
    • Scheduled runs are clearly identified in deployment details.
  • Documentation

    • Documented scheduled deployment behavior, consistency guarantees, retries, quotas, and webhook handling.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This change adds cron-based scheduled deployments with River-backed execution, persistent schedule revisions and cursors, transactional webhook delivery, scheduled-run preparation, typed trigger responses, startup wiring, and integration coverage.

Changes

Scheduled deployment scheduling

Layer / File(s) Summary
Schedule contracts and migration
internal/deployments/cron.go, internal/db/migrations/..., internal/db/deployment_run_mapper.*, docs/design/be/deployments-api-contract.md
Adds cron and timezone handling, deterministic jitter, schedule metadata, migration backfill logic, and typed scheduled-run persistence.
Transactional schedule persistence
internal/db/deployments.go, internal/db/deployment_mapper.*, internal/db/webhooks.go, internal/db/agents.go
Adds schedule revisions, guarded cursor updates, quotas, transactional occurrence processing, archive behavior, and transactional webhook outbox enqueueing.
Scheduler and scheduled-run execution
internal/deployments/scheduler.go, internal/deployments/handler.go, internal/deployments/execution.go, internal/webhooks/enqueuer.go
Adds River scheduling, reconciliation, worker execution, retry classification, auto-pausing, run preparation, and lifecycle webhook preparation.
Application wiring and archival events
main.go, cmd/migrate/main.go, internal/api/server.go, internal/agents/handler.go, internal/config/defaults.go, go.mod
Runs River migrations, starts the scheduler, injects dependencies, adds deployment webhook defaults, and archives agent deployments transactionally.
Integration, API, and client validation
tests/deployments_api_test.go, web/src/features/managed-agents/..., internal/deployments/*_test.go, tests/uuid_boundary_postgres_test.go
Covers startup recovery, transactions, concurrency, rollback, archival, scheduled-run responses, and scheduled-run rendering.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant API
  participant Database
  participant River
  participant Scheduler
  participant WebhookOutbox
  API->>Database: persist schedule and enqueue occurrence
  River->>Scheduler: execute scheduled occurrence
  Scheduler->>Database: validate state and apply run
  Database->>WebhookOutbox: enqueue lifecycle events
  Scheduler->>River: advance or retry occurrence
Loading

Possibly related PRs

Suggested labels: ready-for-agent

Suggested reviewers: cursor

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.54% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: implementing scheduled deployment scheduling with River.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/deployment-scheduler-river

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.

@jh0904
jh0904 marked this pull request as ready for review August 7, 2026 13:15

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approved: Cursor Bugbot was not present after the initial check poll, so that signal was skipped; remaining CI checks passed and no approval policy required human review. No reviewers were assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Approver

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 64c21deb1e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/deployments/scheduler.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
main.go (1)

56-62: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Document the cmd/migrate requirement when AutoMigrate is disabled.

When database.auto_migrate is off, cmd/migrate up still runs Goose migrations and deployments.MigrateRiver before oma-server starts. If an operator starts the server with auto_migrate: false before applying migrations, deploymentScheduler.Start(ctx) fails with missing River tables and only reports start deployment scheduler. Add this ordering to the runbook or run both migration steps unconditionally before starting the scheduler.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.go` around lines 56 - 62, The AutoMigrate-disabled startup path must
ensure both Goose and River migrations run before
deploymentScheduler.Start(ctx). Either document in the runbook that operators
must run cmd/migrate up before starting oma-server, or move database.Migrate and
deployments.MigrateRiver outside the cfg.Database.AutoMigrate guard so both
execute unconditionally.
🧹 Nitpick comments (22)
tests/deployments_api_test.go (1)

720-768: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the success subtest before the failure subtests, or move the failure subtests after it.

The added block places failure auto pause rolls back..., failure scheduled root agent archive..., and failure agent archive rolls back... before agent API archives deployments with webhook outbox. The coding guideline requires failure scenarios first and success scenarios after. Reorder the new subtests so all failure ... subtests precede this success subtest.

As per coding guidelines: "Order tests with failure scenarios before success scenarios".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/deployments_api_test.go` around lines 720 - 768, Reorder the subtests
in the test suite so the failure scenarios “failure auto pause rolls back...”,
“failure scheduled root agent archive...”, and “failure agent archive rolls
back...” appear before the successful “agent API archives deployments with
webhook outbox” subtest. Keep each subtest’s implementation unchanged.

Source: Coding guidelines

docs/design/be/deployments-api-contract.md (1)

88-88: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reference the source of the 14 paused-reason error types.

The count 14 类 paused-reason error will drift when the code list changes. Name the Go constant or slice that holds the list so readers can verify it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/be/deployments-api-contract.md` at line 88, Update the deployment
API contract documentation to reference the Go constant or slice that defines
the 14 public paused-reason error types instead of relying only on the numeric
count. Keep the existing behavior description unchanged, and use the exact
source symbol name so readers can verify the list.
web/src/features/managed-agents/ManagedAgentsPage.resources.suite.tsx (1)

2187-2187: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Align the deployment schedule with the real response shape.

internal/deployments/cron_test.go uses expression, while this fixture uses QuickstartDeploymentInput’s cron_expression; DeploymentApiResponse.schedule has no typed shape. Use the response format or remove this fixture assignment if it is not exercised.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/features/managed-agents/ManagedAgentsPage.resources.suite.tsx` at
line 2187, Update the fixture assignment near api.resources.deployments in the
managed agents resources suite to match the real DeploymentApiResponse schedule
shape by using the response’s expression field instead of
QuickstartDeploymentInput’s cron_expression; if this schedule is not exercised,
remove the assignment.
internal/webhooks/enqueuer_test.go (2)

16-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move this success-scenario test after the failure-scenario test.

TestPrepareDeliveryEventPreservesOutboxData asserts the success path. The existing test that uses failingEnqueueStore at Line 62 asserts a failure path. Place the failure-scenario test first.

As per coding guidelines: "Order tests with failure scenarios before success scenarios".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/webhooks/enqueuer_test.go` around lines 16 - 37, Reorder the tests
in the relevant test file so the existing failure-scenario test using
failingEnqueueStore appears before TestPrepareDeliveryEventPreservesOutboxData.
Do not change either test’s implementation or assertions.

Source: Coding guidelines


22-36: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extend the assertions to cover the fields the test name claims.

The test name states that outbox data is preserved. The assertions cover FallbackEnabled, EventType, CreatedAt, and Data.ID only. event.ID, event.Data.Type, event.Data.WorkspaceID, and event.Data.OrganizationID are the fields that the scheduler's transactional outbox writes depend on, and they are not checked.

Assert those fields as well, and split the combined condition so a failure names the field.

💚 Proposed additional assertions
-	if !deliveryEvent.FallbackEnabled || deliveryEvent.EventType != "deployment_run.started" ||
-		event.CreatedAt != "2026-08-07T01:02:03Z" || event.Data.ID != "drun_test" {
-		t.Fatalf("PrepareDeliveryEvent() = %+v, event = %+v", deliveryEvent, event)
-	}
+	if !deliveryEvent.FallbackEnabled {
+		t.Errorf("FallbackEnabled = false, want true")
+	}
+	if deliveryEvent.EventType != "deployment_run.started" {
+		t.Errorf("EventType = %q, want %q", deliveryEvent.EventType, "deployment_run.started")
+	}
+	if event.CreatedAt != "2026-08-07T01:02:03Z" {
+		t.Errorf("CreatedAt = %q, want %q", event.CreatedAt, "2026-08-07T01:02:03Z")
+	}
+	if !strings.HasPrefix(event.ID, "wevt_") {
+		t.Errorf("ID = %q, want prefix %q", event.ID, "wevt_")
+	}
+	if event.Data.ID != "drun_test" || event.Data.Type != "deployment_run.started" ||
+		event.Data.WorkspaceID != "workspace_test" || event.Data.OrganizationID != "org-uuid" {
+		t.Errorf("Data = %+v", event.Data)
+	}

Add "strings" to the imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/webhooks/enqueuer_test.go` around lines 22 - 36, Extend the
assertions in the PrepareDeliveryEvent test to verify event.ID, event.Data.Type,
event.Data.WorkspaceID, and event.Data.OrganizationID alongside the existing
fields. Split the combined condition into field-specific assertions so failures
identify the mismatched field, and add the strings import only if needed for the
expected workspace or organization value checks.
internal/deployments/scheduler.go (3)

217-220: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

A permanently invalid stored schedule blocks the deployment forever.

nextAfterScheduled returns an error for a schedule that cannot be parsed. Work returns that error, so River retries the job until it is discarded. The schedule cursor never advances, and reconcile skips the same deployment on every pass because jitteredTriggerAt also fails. The deployment then stays active with a stale next_scheduled_at and no operator-visible run.

Record a failure run and auto-pause the deployment for this case, in the same way as other non-retryable preparation failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/deployments/scheduler.go` around lines 217 - 220, Update the
invalid-schedule handling in Work around nextAfterScheduled so an unparseable
stored schedule is treated as a non-retryable preparation failure: record a
failure run and automatically pause the deployment, matching the existing
handling for other non-retryable preparation failures. Ensure Work does not
return the parse error for River retry, and keep reconcile from repeatedly
skipping the unchanged active deployment.

263-288: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

The next occurrence is enqueued only by the 30-second reconcile loop.

After ApplyScheduledOccurrence advances next_scheduled_at, no job is inserted for the new occurrence. The next job appears on the following reconcile tick. This adds up to scheduleReconcileInterval of latency to every occurrence, and it makes minute-level cron schedules imprecise.

Insert the follow-up job directly after a successful apply, and keep the reconcile loop as the recovery path.

♻️ Sketch of the follow-up insert
 	if err != nil {
 		return err
 	}
+	if nextScheduledAt != nil {
+		deployment.ScheduleRevision = args.ScheduleRevision
+		deployment.NextScheduledAt = nextScheduledAt
+		if err := w.enqueueNext(ctx, deployment); err != nil {
+			w.logger.ErrorContext(ctx, "enqueue next deployment occurrence",
+				"deployment_id", deployment.ExternalID, "error", err)
+		}
+	}
 	return nil

enqueueNext needs access to the River client, so pass the client or a small inserter interface into scheduledDeploymentWorker.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/deployments/scheduler.go` around lines 263 - 288, After a successful
ApplyScheduledOccurrence call in the scheduledDeploymentWorker flow, immediately
enqueue the newly computed nextScheduledAt occurrence instead of waiting for
reconciliation. Update scheduledDeploymentWorker or its dependencies to provide
enqueueNext with the River client or a minimal inserter interface, while
preserving the reconcile loop as the recovery path and existing error handling.

82-94: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Start blocks application startup on a full reconcile.

backfillNextScheduledAt and reconcile iterate every scheduled deployment and perform one database write or job insert per deployment. Both run synchronously before client.Start. With a large number of scheduled deployments, this delays process startup, and a single insert error aborts startup through errors.Join.

Consider running the initial reconcile in the background loop, and treating per-deployment errors as logged failures instead of startup failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/deployments/scheduler.go` around lines 82 - 94, The
DeploymentScheduler.Start method currently blocks startup and propagates
per-deployment failures from backfillNextScheduledAt and reconcile. Move the
initial backfill/reconcile work into the background reconciliation flow so
client.Start executes without waiting, and handle individual deployment errors
by logging them while allowing the loop to continue rather than returning them
as startup errors.
main.go (1)

136-142: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

The stop timeout is not aligned with the River client timeouts.

Stop gets 10 seconds. internal/deployments/scheduler.go sets SoftStopTimeout: 10 * time.Second and JobTimeout: 2 * time.Minute. The soft-stop phase alone consumes the whole budget, so the hard-stop phase never gets time, and Stop reports a timeout error on every shutdown that has an in-flight job.

Set the shutdown context longer than SoftStopTimeout, for example 20 seconds.

In-flight work is safe because ApplyScheduledOccurrence runs in one transaction and the job is retried, but the recurring error log is misleading.

♻️ Proposed change
 	defer func() {
-		stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		stopCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
 		defer cancel()
 		if err := deploymentScheduler.Stop(stopCtx); err != nil {
 			logger.Error("stop deployment scheduler", "error", err)
 		}
 	}()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.go` around lines 136 - 142, Increase the shutdown context timeout in the
deferred cleanup around deploymentScheduler.Stop from 10 seconds to a value
longer than the scheduler’s SoftStopTimeout, such as 20 seconds, so the
hard-stop phase can complete without recurring timeout errors.
internal/deployments/execution.go (1)

31-43: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Preparation errors are reported with a misleading error type.

sessionEventsFromInitialEvents, sessionResourcesFromDeployment, and httpapi.MarshalRaw return plain errors. In internal/deployments/scheduler.go at Line 257, every non-retryable error from prepareDeploymentRun is recorded as session_resource_not_found_error. A malformed InitialEvents payload or a marshal failure then produces a wrong error type in the run record, and shouldAutoPause pauses the deployment with that wrong reason.

Return a classified error from prepareDeploymentRun, or wrap each failure source with its own error type before it reaches recordFailure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/deployments/execution.go` around lines 31 - 43, The
prepareDeploymentRun error paths for sessionEventsFromInitialEvents,
sessionResourcesFromDeployment, and httpapi.MarshalRaw currently return
unclassified errors that scheduler.go records as
session_resource_not_found_error. Update prepareDeploymentRun to return a
classified error, or wrap each failure with an appropriate distinct error type,
so recordFailure and shouldAutoPause receive the correct failure reason for
malformed events, resource preparation, and marshal failures.
internal/deployments/handler.go (4)

620-638: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Compute the next occurrence from the row that the transaction unpauses.

Line 620 reads the deployment outside the transaction. Line 625 derives next from that stale copy. A concurrent update to schedule between the read and UnpauseDeploymentTx makes the persisted next_scheduled_at inconsistent with the stored schedule, and the enqueued River job then targets the wrong occurrence. Move the read and the calculation inside the transaction callback, or have UnpauseDeploymentTx derive the next occurrence from the locked row.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/deployments/handler.go` around lines 620 - 638, Move the deployment
read and nextScheduledAt calculation into the transaction callback used by the
unpause flow, ensuring they operate on the transaction’s current or locked row
before calling UnpauseDeploymentTx and enqueueScheduledOccurrenceTx. Keep error
logging and HTTP error responses consistent, and ensure the persisted
next_scheduled_at and enqueued job derive from the same schedule.

775-862: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Split validateRunReferences into per-resource validators.

The function validates six reference kinds in one body. Its cyclomatic complexity is close to the 30 limit for Go functions, so any later branch pushes it over. Extract validateAgentReferences, validateSkillReferences, validateVaultReferences, and validateResourceReferences, then call them in sequence.

As per coding guidelines: "Respect complexity budgets: Go functions must remain at cyclomatic complexity 30 or below".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/deployments/handler.go` around lines 775 - 862, Split
validateRunReferences into the requested helpers: validateAgentReferences,
validateSkillReferences, validateVaultReferences, and
validateResourceReferences. Move each corresponding validation block into its
helper, preserving existing error classification, ordering, and return behavior,
then have validateRunReferences invoke them sequentially and continue only when
each succeeds.

Source: Coding guidelines


289-297: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the local variable to avoid shadowing the nextScheduledAt function.

Line 289 declares a local variable with the same name as the package-level function. The function becomes unreachable for the rest of create. Rename the variable, for example nextRunAt.

♻️ Proposed rename
-	nextScheduledAt, err := nextScheduledAt(schedule, now)
+	nextRunAt, err := nextScheduledAt(schedule, now)
 	if err != nil {
 		writeBadRequest(w, r, err)
 		return
 	}
 	revision := int64(0)
-	if nextScheduledAt != nil {
+	if nextRunAt != nil {
 		revision = 1
 	}

Update the struct field assignment at line 319 as well:

NextScheduledAt: nextRunAt,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/deployments/handler.go` around lines 289 - 297, Rename the local
result variable in create from nextScheduledAt to nextRunAt so it does not
shadow the nextScheduledAt function, update its nil check and revision logic,
and use nextRunAt in the NextScheduledAt struct field assignment.

822-861: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Batch the vault, file, and memory-store lookups.

Lines 828-836 and 843-861 run one database query per reference, in sequence, on the request thread. The deployment resource limit permits up to 500 file resources, so a single POST /v1/deployments/{id}/run can issue hundreds of sequential round trips before any work starts. Add batch lookups by external ID for vaults, files, and memory stores, then validate the returned set.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/deployments/handler.go` around lines 822 - 861, Replace the
sequential lookups in the vault and resources validation flow with
batch-by-external-ID queries for vaults, files, and memory stores, grouping
resource IDs by type before querying. Validate the returned records as a set,
preserving missing-reference and archived-record handling through
classifyReferenceFailure, and keep the existing invalid JSON behavior unchanged.
internal/deployments/scheduler_test.go (1)

1-9: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the worker success path.

This file covers failure classification, auto-pause selection, and failure webhook inputs. It does not cover the successful scheduled execution path, which creates the session, the deployment run, and the outbox rows, and then advances the schedule cursor. The PR description lists this path as a review focus. Do you want me to draft the test?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/deployments/scheduler_test.go` around lines 1 - 9, Add a test in the
scheduler test suite covering the successful scheduled execution path: verify
session, deployment run, and outbox rows are created and the schedule cursor
advances. Reuse the existing test fixtures, helpers, and success-path symbols in
the scheduler implementation, while preserving the current failure, auto-pause,
and webhook tests.
internal/db/deployment_mapper_test.go (1)

168-177: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that AdvanceSchedule leaves schedule_revision unchanged.

The distinction between AdvanceSchedule and PauseAfterScheduledRun is that only the pause statement bumps schedule_revision. If a future edit adds the increment to AdvanceSchedule, every pending River job is invalidated after each successful run and the schedule stalls until the next reconciliation pass. The current fragments do not catch that.

Add a negative assertion in the sub-test block below the table, matching the existing "include archived omits archived filter" pattern.

💚 Proposed test
 	t.Run("include archived omits archived filter", func(t *testing.T) {
 		page.IncludeArchived = true
 		bound := buildDeploymentMapperListPage(yourbatis.DialectPostgres, page)
 		if containsSQL(bound.SQL, "archived_at IS NULL") {
 			t.Fatalf("SQL unexpectedly filters archived deployments: %q", bound.SQL)
 		}
 	})
+
+	t.Run("advance schedule preserves the schedule revision", func(t *testing.T) {
+		bound := buildDeploymentMapperAdvanceSchedule(yourbatis.DialectPostgres, advance)
+		if containsSQL(bound.SQL, "schedule_revision = schedule_revision + 1") {
+			t.Fatalf("AdvanceSchedule unexpectedly bumps the schedule revision: %q", bound.SQL)
+		}
+	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/db/deployment_mapper_test.go` around lines 168 - 177, Extend the
“advance schedule” test case for buildDeploymentMapperAdvanceSchedule with a
negative SQL-fragment assertion, following the existing “include archived omits
archived filter” pattern. Verify the generated statement does not contain any
schedule_revision increment or update, while preserving the existing positive
fragments and argument expectations.
internal/db/migrations/00049_schedule_deployments_with_river.sql (1)

13-31: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoff

Plan the lock impact on deployment_runs.

Static analysis flags several blocking operations on this table:

  • add constraint ... check performs a full table scan and blocks writes. NOT VALID plus a later VALIDATE CONSTRAINT avoids that.
  • Both create index statements block writes. CONCURRENTLY avoids that, but requires -- +goose NO TRANSACTION because goose wraps migrations in a transaction.
  • drop column trigger_context is metadata-only and fast, but it is irreversible for any context field other than scheduled_at.

If deployment_runs is small in every deployed environment, the current form is acceptable. State that decision, or split the migration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/db/migrations/00049_schedule_deployments_with_river.sql` around
lines 13 - 31, Address the lock impact in migration 00049 by either explicitly
confirming that deployment_runs is small in every deployed environment and
keeping the current operations, or split the migration to use NOT VALID followed
by later VALIDATE CONSTRAINT and concurrent index creation with the required
goose NO TRANSACTION directive. Preserve the metadata-only trigger_context
removal, acknowledging its irreversible behavior.

Source: Linters/SAST tools

internal/deployments/cron_test.go (2)

9-72: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Order failure scenarios before success scenarios.

TestNormalizeOptionalScheduleRejectsUnsupportedSyntax is the failure scenario. Move it above TestNextScheduledTimesHandlesLeapDay and TestNextScheduledTimesHandlesDST. The coding guidelines require failure tests first in *_test.go files.

As per coding guidelines: "Order tests with failure scenarios before success scenarios".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/deployments/cron_test.go` around lines 9 - 72, Reorder the test
functions so TestNormalizeOptionalScheduleRejectsUnsupportedSyntax appears
before the successful nextScheduledTimes tests
TestNextScheduledTimesHandlesLeapDay and TestNextScheduledTimesHandlesDST. Keep
each test’s implementation unchanged and retain
TestNormalizeOptionalScheduleAcceptsSundaySeven with the success scenarios.

Source: Coding guidelines


54-65: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a case for an unsatisfiable cron expression.

0 0 30 2 * parses successfully but never matches a date. Add it to this rejection table once the parser rejects it. See the related comment on internal/deployments/cron.go lines 114-158.

💚 Proposed test case
 	tests := []string{
 		`{"type":"cron","expression":"`@daily`","timezone":"UTC"}`,
 		`{"type":"cron","expression":"0 0 L * *","timezone":"UTC"}`,
 		`{"type":"cron","expression":"0 0 0 * * *","timezone":"UTC"}`,
+		`{"type":"cron","expression":"0 0 30 2 *","timezone":"UTC"}`,
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/deployments/cron_test.go` around lines 54 - 65, Add the
unsatisfiable expression `0 0 30 2 *` to the `tests` table in
`TestNormalizeOptionalScheduleRejectsUnsupportedSyntax`, preserving the existing
assertion that `normalizeOptionalSchedule` returns an error for every listed
expression. Ensure the parser and normalization logic reject this expression
before enabling the test.
internal/db/deployment_mapper.xml (1)

224-233: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

This query has no supporting index and runs on every reconciliation tick.

The partial index deployments_pending_schedule_idx added in migration 00049 requires next_scheduled_at IS NOT NULL. This query requires the opposite, so PostgreSQL falls back to a sequential scan of deployments. The scheduler runs the query every 30 seconds across all tenants.

Add a matching partial index in the migration.

⚡ Proposed index for `internal/db/migrations/00049_schedule_deployments_with_river.sql`
 create index deployments_pending_schedule_idx
 	on deployments (next_scheduled_at)
 	where status = 'active'
 		and archived_at is null
 		and deleted_at is null
 		and next_scheduled_at is not null;
 
+create index deployments_uninitialized_schedule_idx
+	on deployments (uuid)
+	where status = 'active'
+		and archived_at is null
+		and deleted_at is null
+		and schedule is not null
+		and next_scheduled_at is null;
+

Add the matching drop index deployments_uninitialized_schedule_idx; to the Down section.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/db/deployment_mapper.xml` around lines 224 - 233, Add a partial
index in migration 00049 matching the ListSchedulesMissingNextScheduledAt
predicates, covering active, non-archived, non-deleted deployments with a
non-null schedule and null next_scheduled_at; add the corresponding
deployments_uninitialized_schedule_idx drop statement to the migration’s Down
section.
internal/db/webhooks.go (1)

70-98: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Cache the endpoint lookup per event type.

ListActiveForEvent runs once per event inside the loop. The agent-archive cascade in internal/db/agents.go builds one deployment.archived event per archived deployment, so the same query repeats for every deployment while holding the transaction open. This is an N+1 query on a write transaction.

hasEndpoints is also loop-invariant, so the fallback branch can move outside the loop.

♻️ Proposed refactor
 	jobMapper := NewWebhookDeliveryJobMapper(executor)
+	if !hasEndpoints {
+		for _, event := range events {
+			if !event.FallbackEnabled {
+				continue
+			}
+			payload, err := webhookDeliveryJobPayloadJSON(event.EventType, event.Event, "")
+			if err != nil {
+				return err
+			}
+			if err := jobMapper.Insert(ctx, workspaceUUID, payload); err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+
+	endpointsByEventType := make(map[string][]webhookEndpointMapperRow, len(events))
 	for _, event := range events {
-		if !hasEndpoints {
-			if !event.FallbackEnabled {
-				continue
-			}
-			payload, err := webhookDeliveryJobPayloadJSON(event.EventType, event.Event, "")
-			if err != nil {
-				return err
-			}
-			if err := jobMapper.Insert(ctx, workspaceUUID, payload); err != nil {
-				return err
-			}
-			continue
-		}
-
-		endpoints, err := endpointMapper.ListActiveForEvent(ctx, workspaceUUID, event.EventType)
-		if err != nil {
-			return err
+		endpoints, cached := endpointsByEventType[event.EventType]
+		if !cached {
+			var err error
+			endpoints, err = endpointMapper.ListActiveForEvent(ctx, workspaceUUID, event.EventType)
+			if err != nil {
+				return err
+			}
+			endpointsByEventType[event.EventType] = endpoints
 		}
 		for _, endpoint := range endpoints {

Adjust the map value type to the actual row type returned by ListActiveForEvent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/db/webhooks.go` around lines 70 - 98, Refactor the event processing
loop around ListActiveForEvent and hasEndpoints to cache active endpoints by
event type, using the actual row type returned by
endpointMapper.ListActiveForEvent for the map values. Reuse cached results for
repeated event types so each type is queried once, and move the loop-invariant
fallback handling for !hasEndpoints outside the endpoint lookup path while
preserving existing payload and insertion behavior.
internal/deployments/cron.go (1)

68-72: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add an explicit default or make schedule.timezone required.

An empty schedule.timezone passes validation and time.LoadLocation("") treats it as UTC, but the API does not document this fallback. Set an explicit UTC option before loading the location, or reject an omitted/blank timezone if the user must always choose a timezone.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/deployments/cron.go` around lines 68 - 72, Update the timezone
validation in the schedule parsing flow around config.Timezone and
time.LoadLocation: either assign an explicit UTC default when the trimmed value
is blank, or reject blank values as invalid if timezone selection is required.
Ensure the chosen behavior is explicit and consistent with the API contract
before loading the location.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@go.mod`:
- Line 84: Upgrade the google.golang.org/grpc dependency from v1.80.0 to at
least v1.82.1 in go.mod, preserving it as an explicit requirement if needed, and
regenerate go.sum so the updated module checksums are recorded.

In `@internal/db/deployment_mapper.xml`:
- Around line 144-148: Update the schedule-change detection in
UpdateDeploymentTx before it calls UpdateByExternalID so ScheduleChanged is true
only when the normalized new schedule differs from the existing normalized
schedule. Reuse the same normalization/serialization semantics used for
persistence, then pass the comparison result into the existing deployment update
flow so schedule_revision is not incremented for equivalent schedules.

In `@internal/db/deployments.go`:
- Around line 319-326: Update SetInitialDeploymentNextScheduledAt to preserve
the rows-affected result from SetInitialNextScheduledAt and return
ErrStaleSchedule when the guarded update affects zero rows, while returning nil
for a successful update and propagating database errors. Ensure the scheduler
handles ErrStaleSchedule by skipping the River enqueue, consistent with the
worker’s existing stale-schedule handling.

In `@internal/db/migrations/00049_schedule_deployments_with_river.sql`:
- Around line 9-21: Before updating deployment_runs, add explicit validation for
existing schedule rows in the migration: fail with a clear error when
trigger_context.scheduled_at is missing or invalid, and when duplicate
deployment_uuid/scheduled_at occurrences would violate the new unique index.
Only perform the backfill and add deployment_runs_scheduled_at_check and
deployment_runs_schedule_occurrence_idx after these validations pass.

In `@internal/deployments/cron.go`:
- Around line 114-158: The cron schedule handling must reject unsatisfiable
expressions instead of persisting or returning zero times. In
internal/deployments/cron.go lines 114-158, update parseDeploymentSchedule to
reject a zero first occurrence, make nextScheduledAt and nextAfterScheduled
return nil when cron.Schedule.Next yields zero, and update jitteredTriggerAt at
lines 165-176 to handle a nil result before dereferencing next. In
internal/deployments/cron_test.go lines 54-65, add the 0 0 30 2 * UTC schedule
to TestNormalizeOptionalScheduleRejectsUnsupportedSyntax’s rejection table.

In `@web/src/features/managed-agents/resources/detail.tsx`:
- Line 680: Guard the trigger context access in the runs table so missing
trigger_context does not throw and preserves the prior fallback behavior.
Restore or reuse triggerLabel for user-facing formatting instead of rendering
raw enum values, unless the intended copy explicitly requires manual and
schedule.

---

Outside diff comments:
In `@main.go`:
- Around line 56-62: The AutoMigrate-disabled startup path must ensure both
Goose and River migrations run before deploymentScheduler.Start(ctx). Either
document in the runbook that operators must run cmd/migrate up before starting
oma-server, or move database.Migrate and deployments.MigrateRiver outside the
cfg.Database.AutoMigrate guard so both execute unconditionally.

---

Nitpick comments:
In `@docs/design/be/deployments-api-contract.md`:
- Line 88: Update the deployment API contract documentation to reference the Go
constant or slice that defines the 14 public paused-reason error types instead
of relying only on the numeric count. Keep the existing behavior description
unchanged, and use the exact source symbol name so readers can verify the list.

In `@internal/db/deployment_mapper_test.go`:
- Around line 168-177: Extend the “advance schedule” test case for
buildDeploymentMapperAdvanceSchedule with a negative SQL-fragment assertion,
following the existing “include archived omits archived filter” pattern. Verify
the generated statement does not contain any schedule_revision increment or
update, while preserving the existing positive fragments and argument
expectations.

In `@internal/db/deployment_mapper.xml`:
- Around line 224-233: Add a partial index in migration 00049 matching the
ListSchedulesMissingNextScheduledAt predicates, covering active, non-archived,
non-deleted deployments with a non-null schedule and null next_scheduled_at; add
the corresponding deployments_uninitialized_schedule_idx drop statement to the
migration’s Down section.

In `@internal/db/migrations/00049_schedule_deployments_with_river.sql`:
- Around line 13-31: Address the lock impact in migration 00049 by either
explicitly confirming that deployment_runs is small in every deployed
environment and keeping the current operations, or split the migration to use
NOT VALID followed by later VALIDATE CONSTRAINT and concurrent index creation
with the required goose NO TRANSACTION directive. Preserve the metadata-only
trigger_context removal, acknowledging its irreversible behavior.

In `@internal/db/webhooks.go`:
- Around line 70-98: Refactor the event processing loop around
ListActiveForEvent and hasEndpoints to cache active endpoints by event type,
using the actual row type returned by endpointMapper.ListActiveForEvent for the
map values. Reuse cached results for repeated event types so each type is
queried once, and move the loop-invariant fallback handling for !hasEndpoints
outside the endpoint lookup path while preserving existing payload and insertion
behavior.

In `@internal/deployments/cron_test.go`:
- Around line 9-72: Reorder the test functions so
TestNormalizeOptionalScheduleRejectsUnsupportedSyntax appears before the
successful nextScheduledTimes tests TestNextScheduledTimesHandlesLeapDay and
TestNextScheduledTimesHandlesDST. Keep each test’s implementation unchanged and
retain TestNormalizeOptionalScheduleAcceptsSundaySeven with the success
scenarios.
- Around line 54-65: Add the unsatisfiable expression `0 0 30 2 *` to the
`tests` table in `TestNormalizeOptionalScheduleRejectsUnsupportedSyntax`,
preserving the existing assertion that `normalizeOptionalSchedule` returns an
error for every listed expression. Ensure the parser and normalization logic
reject this expression before enabling the test.

In `@internal/deployments/cron.go`:
- Around line 68-72: Update the timezone validation in the schedule parsing flow
around config.Timezone and time.LoadLocation: either assign an explicit UTC
default when the trimmed value is blank, or reject blank values as invalid if
timezone selection is required. Ensure the chosen behavior is explicit and
consistent with the API contract before loading the location.

In `@internal/deployments/execution.go`:
- Around line 31-43: The prepareDeploymentRun error paths for
sessionEventsFromInitialEvents, sessionResourcesFromDeployment, and
httpapi.MarshalRaw currently return unclassified errors that scheduler.go
records as session_resource_not_found_error. Update prepareDeploymentRun to
return a classified error, or wrap each failure with an appropriate distinct
error type, so recordFailure and shouldAutoPause receive the correct failure
reason for malformed events, resource preparation, and marshal failures.

In `@internal/deployments/handler.go`:
- Around line 620-638: Move the deployment read and nextScheduledAt calculation
into the transaction callback used by the unpause flow, ensuring they operate on
the transaction’s current or locked row before calling UnpauseDeploymentTx and
enqueueScheduledOccurrenceTx. Keep error logging and HTTP error responses
consistent, and ensure the persisted next_scheduled_at and enqueued job derive
from the same schedule.
- Around line 775-862: Split validateRunReferences into the requested helpers:
validateAgentReferences, validateSkillReferences, validateVaultReferences, and
validateResourceReferences. Move each corresponding validation block into its
helper, preserving existing error classification, ordering, and return behavior,
then have validateRunReferences invoke them sequentially and continue only when
each succeeds.
- Around line 289-297: Rename the local result variable in create from
nextScheduledAt to nextRunAt so it does not shadow the nextScheduledAt function,
update its nil check and revision logic, and use nextRunAt in the
NextScheduledAt struct field assignment.
- Around line 822-861: Replace the sequential lookups in the vault and resources
validation flow with batch-by-external-ID queries for vaults, files, and memory
stores, grouping resource IDs by type before querying. Validate the returned
records as a set, preserving missing-reference and archived-record handling
through classifyReferenceFailure, and keep the existing invalid JSON behavior
unchanged.

In `@internal/deployments/scheduler_test.go`:
- Around line 1-9: Add a test in the scheduler test suite covering the
successful scheduled execution path: verify session, deployment run, and outbox
rows are created and the schedule cursor advances. Reuse the existing test
fixtures, helpers, and success-path symbols in the scheduler implementation,
while preserving the current failure, auto-pause, and webhook tests.

In `@internal/deployments/scheduler.go`:
- Around line 217-220: Update the invalid-schedule handling in Work around
nextAfterScheduled so an unparseable stored schedule is treated as a
non-retryable preparation failure: record a failure run and automatically pause
the deployment, matching the existing handling for other non-retryable
preparation failures. Ensure Work does not return the parse error for River
retry, and keep reconcile from repeatedly skipping the unchanged active
deployment.
- Around line 263-288: After a successful ApplyScheduledOccurrence call in the
scheduledDeploymentWorker flow, immediately enqueue the newly computed
nextScheduledAt occurrence instead of waiting for reconciliation. Update
scheduledDeploymentWorker or its dependencies to provide enqueueNext with the
River client or a minimal inserter interface, while preserving the reconcile
loop as the recovery path and existing error handling.
- Around line 82-94: The DeploymentScheduler.Start method currently blocks
startup and propagates per-deployment failures from backfillNextScheduledAt and
reconcile. Move the initial backfill/reconcile work into the background
reconciliation flow so client.Start executes without waiting, and handle
individual deployment errors by logging them while allowing the loop to continue
rather than returning them as startup errors.

In `@internal/webhooks/enqueuer_test.go`:
- Around line 16-37: Reorder the tests in the relevant test file so the existing
failure-scenario test using failingEnqueueStore appears before
TestPrepareDeliveryEventPreservesOutboxData. Do not change either test’s
implementation or assertions.
- Around line 22-36: Extend the assertions in the PrepareDeliveryEvent test to
verify event.ID, event.Data.Type, event.Data.WorkspaceID, and
event.Data.OrganizationID alongside the existing fields. Split the combined
condition into field-specific assertions so failures identify the mismatched
field, and add the strings import only if needed for the expected workspace or
organization value checks.

In `@main.go`:
- Around line 136-142: Increase the shutdown context timeout in the deferred
cleanup around deploymentScheduler.Stop from 10 seconds to a value longer than
the scheduler’s SoftStopTimeout, such as 20 seconds, so the hard-stop phase can
complete without recurring timeout errors.

In `@tests/deployments_api_test.go`:
- Around line 720-768: Reorder the subtests in the test suite so the failure
scenarios “failure auto pause rolls back...”, “failure scheduled root agent
archive...”, and “failure agent archive rolls back...” appear before the
successful “agent API archives deployments with webhook outbox” subtest. Keep
each subtest’s implementation unchanged.

In `@web/src/features/managed-agents/ManagedAgentsPage.resources.suite.tsx`:
- Line 2187: Update the fixture assignment near api.resources.deployments in the
managed agents resources suite to match the real DeploymentApiResponse schedule
shape by using the response’s expression field instead of
QuickstartDeploymentInput’s cron_expression; if this schedule is not exercised,
remove the assignment.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 468a4729-af4c-4a0c-9e8f-6cd499959c24

📥 Commits

Reviewing files that changed from the base of the PR and between 1d1617f and 64c21de.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (35)
  • cmd/migrate/main.go
  • docs/design/be/deployments-api-contract.md
  • go.mod
  • internal/agents/handler.go
  • internal/api/server.go
  • internal/config/defaults.go
  • internal/db/agents.go
  • internal/db/db.go
  • internal/db/deployment_mapper.go
  • internal/db/deployment_mapper.xml
  • internal/db/deployment_mapper_test.go
  • internal/db/deployment_run_mapper.go
  • internal/db/deployment_run_mapper.xml
  • internal/db/deployments.go
  • internal/db/migrations/00049_schedule_deployments_with_river.sql
  • internal/db/migrations_test.go
  • internal/db/webhooks.go
  • internal/deployments/cron.go
  • internal/deployments/cron_test.go
  • internal/deployments/execution.go
  • internal/deployments/handler.go
  • internal/deployments/handler_contract_test.go
  • internal/deployments/resources.go
  • internal/deployments/scheduler.go
  • internal/deployments/scheduler_test.go
  • internal/webhooks/enqueuer.go
  • internal/webhooks/enqueuer_test.go
  • main.go
  • tests/deployments_api_test.go
  • tests/uuid_boundary_postgres_test.go
  • web/src/features/managed-agents/ManagedAgentsPage.resources.suite.tsx
  • web/src/features/managed-agents/ManagedAgentsPage.test-utils.tsx
  • web/src/features/managed-agents/resources/detail.tsx
  • web/src/features/managed-agents/resources/model.tsx
  • web/src/features/managed-agents/types.ts
💤 Files with no reviewable changes (1)
  • web/src/features/managed-agents/resources/model.tsx

Comment thread go.mod
golang.org/x/tools v0.47.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
google.golang.org/grpc v1.80.0 // indirect

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Resolve the first patched grpc version and confirm the River release.
set -euo pipefail

gh api graphql -f query='
{
  securityVulnerabilities(first: 10, ecosystem: GO, package: "google.golang.org/grpc") {
    nodes {
      advisory { ghsaId summary severity publishedAt }
      vulnerableVersionRange
      firstPatchedVersion { identifier }
    }
  }
}'

curl -s "https://proxy.golang.org/google.golang.org/grpc/@latest" | jq '.Version'
curl -s "https://proxy.golang.org/github.com/riverqueue/river/@v/v0.42.0.info" | jq '.'

Repository: superduck-ai/open-managed-agents

Length of output: 2017


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== go.mod grpc entries =="
rg -n 'google\.golang\.org/grpc|google\.golang\.org/genproto|google\.golang\.org/protobuf|require \(' go.mod

echo
echo "== first 130 lines =="
sed -n '1,130p' go.mod

echo
echo "== downstream direct deps requiring grpc v1.80.0 =="
rg -n 'google\.golang\.org/grpc v1\.80\.0|google\.golang\.org/grpc \v1\.80\.0' .

Repository: superduck-ai/open-managed-agents

Length of output: 4675


Upgrade google.golang.org/grpc to patch GHSA-hrxh-6v49-42gf.

v1.80.0 is vulnerable; add an explicit require or run go get for at least google.golang.org/grpc v1.82.1, then update go.sum.

🧰 Tools
🪛 OSV Scanner (2.4.0)

[HIGH] 84-84: google.golang.org/grpc 1.80.0: Vulnerabilities in the xDS RBAC authorization engine and the HTTP/2 transport server implementation in google.golang.org/grpc

(GO-2026-6061)


[HIGH] 84-84: google.golang.org/grpc 1.80.0: gRPC-Go: xDS RBAC and HTTP/2 Vulnerabilities

(GHSA-hrxh-6v49-42gf)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go.mod` at line 84, Upgrade the google.golang.org/grpc dependency from
v1.80.0 to at least v1.82.1 in go.mod, preserving it as an explicit requirement
if needed, and regenerate go.sum so the updated module checksums are recorded.

Source: Linters/SAST tools

Comment thread internal/db/deployment_mapper.xml
Comment on lines +319 to +326
func (d *DB) SetInitialDeploymentNextScheduledAt(ctx context.Context, state DeploymentScheduleState, nextScheduledAt time.Time) error {
deploymentMapper := NewDeploymentMapper(d.mapperDB)
_, err := deploymentMapper.SetInitialNextScheduledAt(ctx, setInitialNextScheduledAtParams{
WorkspaceUUID: state.WorkspaceUUID, ExternalID: state.ExternalID,
ScheduleRevision: state.ScheduleRevision, NextScheduledAt: nextScheduledAt,
})
return err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not discard the rows-affected count from the guarded update.

SetInitialNextScheduledAt is a conditional update. It matches zero rows when the schedule revision advanced, when the deployment was paused or archived, or when next_scheduled_at was already set by a concurrent reconciler. SetInitialDeploymentNextScheduledAt drops that count and returns nil, so the caller cannot distinguish "cursor persisted" from "nothing changed".

The caller then enqueues a River job for a cursor that was never stored. The worker rejects it as stale and the occurrence is skipped until the next reconciliation tick.

Return the outcome so the scheduler can skip the enqueue.

🛡️ Proposed fix
-func (d *DB) SetInitialDeploymentNextScheduledAt(ctx context.Context, state DeploymentScheduleState, nextScheduledAt time.Time) error {
+func (d *DB) SetInitialDeploymentNextScheduledAt(ctx context.Context, state DeploymentScheduleState, nextScheduledAt time.Time) error {
 	deploymentMapper := NewDeploymentMapper(d.mapperDB)
-	_, err := deploymentMapper.SetInitialNextScheduledAt(ctx, setInitialNextScheduledAtParams{
+	rowsAffected, err := deploymentMapper.SetInitialNextScheduledAt(ctx, setInitialNextScheduledAtParams{
 		WorkspaceUUID: state.WorkspaceUUID, ExternalID: state.ExternalID,
 		ScheduleRevision: state.ScheduleRevision, NextScheduledAt: nextScheduledAt,
 	})
-	return err
+	if err != nil {
+		return err
+	}
+	if rowsAffected != 1 {
+		return ErrStaleSchedule
+	}
+	return nil
 }

The scheduler must then treat ErrStaleSchedule as a skip, matching the handling already present in the worker.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/db/deployments.go` around lines 319 - 326, Update
SetInitialDeploymentNextScheduledAt to preserve the rows-affected result from
SetInitialNextScheduledAt and return ErrStaleSchedule when the guarded update
affects zero rows, while returning nil for a successful update and propagating
database errors. Ensure the scheduler handles ErrStaleSchedule by skipping the
River enqueue, consistent with the worker’s existing stale-schedule handling.

Comment on lines +9 to +21
update deployment_runs
set scheduled_at = (trigger_context ->> 'scheduled_at')::timestamptz
where trigger_type = 'schedule';

alter table deployment_runs
add constraint deployment_runs_scheduled_at_check check (
(trigger_type = 'schedule' and scheduled_at is not null)
or (trigger_type <> 'schedule' and scheduled_at is null)
);

create unique index deployment_runs_schedule_occurrence_idx
on deployment_runs (deployment_uuid, scheduled_at)
where trigger_type = 'schedule';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

The migration aborts if existing schedule rows have incomplete or duplicate trigger context.

Two failure modes exist on pre-existing data:

  1. A row with trigger_type = 'schedule' whose trigger_context lacks a scheduled_at key backfills to NULL. The deployment_runs_scheduled_at_check constraint then rejects it and the migration aborts.
  2. Two rows with the same deployment_uuid and the same backfilled scheduled_at break the new unique index deployment_runs_schedule_occurrence_idx.

Failing is better than silently writing NULL, and it matches the repository rule for reference backfills. But the failure message is opaque. Verify the current data first, and consider a pre-check that raises an explicit error.

As per coding guidelines: "将既有 bigint 引用迁移为 UUID 时,必须先通过源表回填并验证每一条引用均可解析;发现孤立引用时必须让 migration 失败,不能静默写成 NULL" — the same verify-then-fail principle applies to this backfill.

🛡️ Proposed explicit pre-check
 update deployment_runs
 set scheduled_at = (trigger_context ->> 'scheduled_at')::timestamptz
 where trigger_type = 'schedule';
 
+-- +goose StatementBegin
+do $$
+begin
+	if exists (select 1 from deployment_runs where trigger_type = 'schedule' and scheduled_at is null) then
+		raise exception 'deployment_runs rows with trigger_type = schedule are missing trigger_context.scheduled_at';
+	end if;
+	if exists (
+		select 1 from deployment_runs where trigger_type = 'schedule'
+		group by deployment_uuid, scheduled_at having count(*) > 1
+	) then
+		raise exception 'duplicate scheduled occurrences exist for the same deployment_uuid and scheduled_at';
+	end if;
+end
+$$;
+-- +goose StatementEnd
+
 alter table deployment_runs
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
update deployment_runs
set scheduled_at = (trigger_context ->> 'scheduled_at')::timestamptz
where trigger_type = 'schedule';
alter table deployment_runs
add constraint deployment_runs_scheduled_at_check check (
(trigger_type = 'schedule' and scheduled_at is not null)
or (trigger_type <> 'schedule' and scheduled_at is null)
);
create unique index deployment_runs_schedule_occurrence_idx
on deployment_runs (deployment_uuid, scheduled_at)
where trigger_type = 'schedule';
update deployment_runs
set scheduled_at = (trigger_context ->> 'scheduled_at')::timestamptz
where trigger_type = 'schedule';
-- +goose StatementBegin
do $$
begin
if exists (select 1 from deployment_runs where trigger_type = 'schedule' and scheduled_at is null) then
raise exception 'deployment_runs rows with trigger_type = schedule are missing trigger_context.scheduled_at';
end if;
if exists (
select 1 from deployment_runs where trigger_type = 'schedule'
group by deployment_uuid, scheduled_at having count(*) > 1
) then
raise exception 'duplicate scheduled occurrences exist for the same deployment_uuid and scheduled_at';
end if;
end
$$;
-- +goose StatementEnd
alter table deployment_runs
add constraint deployment_runs_scheduled_at_check check (
(trigger_type = 'schedule' and scheduled_at is not null)
or (trigger_type <> 'schedule' and scheduled_at is null)
);
create unique index deployment_runs_schedule_occurrence_idx
on deployment_runs (deployment_uuid, scheduled_at)
where trigger_type = 'schedule';
🧰 Tools
🪛 Squawk (2.61.0)

[warning] 14-17: By default new constraints require a table scan and block writes to the table while that scan occurs. Use NOT VALID with a later VALIDATE CONSTRAINT call.

(constraint-missing-not-valid)


[warning] 19-21: During normal index creation, table updates are blocked, but reads are still allowed. Use concurrently to avoid blocking writes.

(require-concurrent-index-creation)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/db/migrations/00049_schedule_deployments_with_river.sql` around
lines 9 - 21, Before updating deployment_runs, add explicit validation for
existing schedule rows in the migration: fail with a clear error when
trigger_context.scheduled_at is missing or invalid, and when duplicate
deployment_uuid/scheduled_at occurrences would violate the new unique index.
Only perform the backfill and add deployment_runs_scheduled_at_check and
deployment_runs_schedule_occurrence_idx after these validations pass.

Source: Coding guidelines

Comment thread internal/deployments/cron.go
compactEntityId(run.id),
deploymentRunStatus(run),
run.trigger_type || triggerLabel(run.trigger),
run.trigger_context.type,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the trigger context access and confirm the dropped label formatting.

listDeploymentRuns returns the API payload without runtime validation. If trigger_context is absent in a response, run.trigger_context.type throws and the whole runs table fails to render. The previous expression tolerated missing fields.

This change also renders the raw enum value. The removed triggerLabel helper produced a display label. Confirm that showing manual and schedule verbatim is the intended copy.

🛡️ Proposed guard
-        run.trigger_context.type,
+        run.trigger_context?.type ?? '—',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
run.trigger_context.type,
run.trigger_context?.type ?? '—',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/features/managed-agents/resources/detail.tsx` at line 680, Guard the
trigger context access in the runs table so missing trigger_context does not
throw and preserves the prior fallback behavior. Restore or reuse triggerLabel
for user-facing formatting instead of rendering raw enum values, unless the
intended copy explicitly requires manual and schedule.

@jh0904

jh0904 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

这轮只修了 4 个正确性问题:Workspace 归档、无解 Cron、unpause 旧快照和执行配置 revision。30 秒 reconcile 和 Worker 全链路测试没动,按之前结论另算。

麻烦再看一轮。

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 460a222725

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
tests/deployments_api_test.go (1)

557-568: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the lock-contention assertions deterministic.

The tests use elapsed time without proving that the competing operation reached the database lock boundary. A delayed goroutine can make either test pass without testing the required blocking behavior.

  • tests/deployments_api_test.go#L557-L568: Synchronize after UpdateDeployment reaches its row-lock attempt before committing workerTx.
  • tests/deployments_api_test.go#L658-L680: Signal after the unpause request reaches its row-lock attempt, not before app.client.Do(req).

Use a test hook or observable database lock state. Do not use a fixed sleep as the synchronization condition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/deployments_api_test.go` around lines 557 - 568, Make the
lock-contention assertions deterministic in tests/deployments_api_test.go at
lines 557-568 and 658-680: add a test hook or observable database lock-state
signal so the worker synchronizes only after UpdateDeployment reaches its
row-lock attempt before committing workerTx, and the unpause test signals only
after app.client.Do(req) reaches its row-lock attempt. Replace the fixed
time-based synchronization with these signals; do not use sleeps or elapsed-time
checks.
docs/design/be/deployments-api-contract.md (1)

95-95: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

定义 deployment_run webhook 的顺序和幂等处理规则。

succeeded/failedstarted 是当前独立 jobs 记录。订单号相同的 jobs 只能按 run_after/created_at 排序领取,当前实现不会按 Run 串行投递;重试或并发交付 may 导致终态事件先到达或重复送达。在合同中补充顺序键、重复记录规则,或实现/约束 Run 级别的 webhook delivery worker 串行性。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/be/deployments-api-contract.md` at line 95, 补充 deployment_run
webhook 的顺序与幂等契约:明确以同一 Run ID 作为顺序键,started 必须先于 succeeded/failed
投递,并规定重试或并发导致的重复事件及终态先到达时的处理规则;若无法仅通过合同保证,则约束对应 webhook delivery worker 按 Run
串行投递。
internal/db/deployment_mapper.xml (1)

23-24: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Run the Yourbatis generator for DeploymentMapper.

internal/db/deployment_mapper.go declares deployment_mapper.sqlmap.gen.go as generator output, and the XML now adds schedule_revision, next_scheduled_at, and the schedule-related statements. Regenerate matching generated code rather than shipping stale SQL-map output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/db/deployment_mapper.xml` around lines 23 - 24, Regenerate the
MyBatis output for DeploymentMapper so deployment_mapper.sqlmap.gen.go reflects
the XML additions, including schedule_revision, next_scheduled_at, and the
schedule-related statements. Do not manually patch the generated file; run the
repository’s established generator and include the resulting synchronized
output.

Source: Coding guidelines

🧹 Nitpick comments (1)
internal/db/deployment_mapper.xml (1)

224-243: 🚀 Performance & Scalability | 🔵 Trivial

Bound the reconciliation reads before scale-up.

Both queries return all matching deployments without a LIMIT. With a 30-second reconciliation loop, each cycle can materialize the full active schedule set. Add keyset pagination or bounded batches, or validate that the expected schedule count and indexes keep this cost acceptable.

This follows the PR objective of a 30-second reconciliation loop.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/db/deployment_mapper.xml` around lines 224 - 243, The
ListActiveSchedules and ListSchedulesMissingNextScheduledAt queries currently
read unbounded result sets each reconciliation cycle. Add bounded batching with
keyset pagination using stable ordering keys, or otherwise enforce an
appropriate LIMIT and continuation mechanism, while preserving their existing
filters and ordering so reconciliation remains scalable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/design/be/deployments-api-contract.md`:
- Line 65: 明确文档中的终态 Run 规则:每个 scheduled occurrence 都必须创建对应的 deployment_run,并为其生成
Run ID 和 webhook;创建 Session、Deployment Run 与 outbox 必须保持原子性。更新游标规则,使提交任一终态
Run(成功或非自动暂停的最终失败)后都推进到下一个 occurrence,仅自动暂停的失败停止推进。

---

Outside diff comments:
In `@docs/design/be/deployments-api-contract.md`:
- Line 95: 补充 deployment_run webhook 的顺序与幂等契约:明确以同一 Run ID 作为顺序键,started 必须先于
succeeded/failed 投递,并规定重试或并发导致的重复事件及终态先到达时的处理规则;若无法仅通过合同保证,则约束对应 webhook
delivery worker 按 Run 串行投递。

In `@internal/db/deployment_mapper.xml`:
- Around line 23-24: Regenerate the MyBatis output for DeploymentMapper so
deployment_mapper.sqlmap.gen.go reflects the XML additions, including
schedule_revision, next_scheduled_at, and the schedule-related statements. Do
not manually patch the generated file; run the repository’s established
generator and include the resulting synchronized output.

In `@tests/deployments_api_test.go`:
- Around line 557-568: Make the lock-contention assertions deterministic in
tests/deployments_api_test.go at lines 557-568 and 658-680: add a test hook or
observable database lock-state signal so the worker synchronizes only after
UpdateDeployment reaches its row-lock attempt before committing workerTx, and
the unpause test signals only after app.client.Do(req) reaches its row-lock
attempt. Replace the fixed time-based synchronization with these signals; do not
use sleeps or elapsed-time checks.

---

Nitpick comments:
In `@internal/db/deployment_mapper.xml`:
- Around line 224-243: The ListActiveSchedules and
ListSchedulesMissingNextScheduledAt queries currently read unbounded result sets
each reconciliation cycle. Add bounded batching with keyset pagination using
stable ordering keys, or otherwise enforce an appropriate LIMIT and continuation
mechanism, while preserving their existing filters and ordering so
reconciliation remains scalable.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 087e8caa-7ffe-48d3-bea5-f1ab2dac9ca2

📥 Commits

Reviewing files that changed from the base of the PR and between 64c21de and 460a222.

📒 Files selected for processing (11)
  • docs/design/be/deployments-api-contract.md
  • internal/db/db.go
  • internal/db/deployment_mapper.go
  • internal/db/deployment_mapper.xml
  • internal/db/deployment_mapper_test.go
  • internal/db/deployments.go
  • internal/deployments/cron.go
  • internal/deployments/cron_test.go
  • internal/deployments/handler.go
  • internal/deployments/scheduler.go
  • tests/deployments_api_test.go
🚧 Files skipped from review as they are similar to previous changes (7)
  • internal/db/db.go
  • internal/db/deployment_mapper.go
  • internal/db/deployment_mapper_test.go
  • internal/deployments/scheduler.go
  • internal/deployments/cron.go
  • internal/db/deployments.go
  • internal/deployments/handler.go

- spring-forward 不存在的墙上时刻不触发;fall-back 重复的墙上时刻触发两次。
- 实际 River Job 只正向延后。jitter 窗口为相邻名义 occurrence 间隔的 15%,下限 5 秒、上限 9 分钟;窗口内 offset 由 Deployment ID 与名义时刻的稳定哈希决定。这是 OMA 内部选择,不是 Claude 公开的哈希算法。

每个 Deployment 持久化 `schedule_revision` 和 `next_scheduled_at`。这个 revision 同时保护 schedule 和执行输入:create、明确修改或清除 schedule、修改 agent/environment/metadata/initial events/resources/vaults、pause、unpause、archive 都使旧 Job 失效;不影响执行的 PATCH 不改 revision。执行输入 PATCH 会保留事务内锁定行的 cursor,并为新 revision 重新入队,避免 worker 使用旧配置提交 Session。unpause 在锁定 Deployment 后计算下一个 occurrence,只从当前时间之后恢复,不补暂停期间的触发。worker 成功提交一个 Run 后推进到下一个名义 occurrence。create、相关 PATCH 和 unpause 通过 Yourbatis 公开的 `SQLTx()` 将同一个事务交给 River `InsertTx`,使游标与 Job 一起提交或回滚。worker 推进游标和启动回填后的 Job 仍由每 30 秒一次的 reconciliation 补齐,入队使用 `ByArgs` 保持幂等。启动回填或 reconciliation 遇到单条确定性的存量 schedule 解析错误时记录并跳过该 Deployment;数据库或 River 基础设施错误仍使启动失败。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

明确所有终态 Run 的创建和游标推进规则。

Line 79 的“Session 或失败 Run”没有明确创建成功的 deployment_run。Line 83 和 Line 95 要求每个 scheduled Run 具有对应的 Run ID 和 webhook;PR objective 也要求原子创建 Session、Deployment Run 和 outbox。

Line 65 的“成功提交一个 Run 后推进”也容易漏掉不暂停的最终失败。应在提交任一终态 Run 后推进游标;只有自动暂停的失败才停止推进。

建议修改
- worker 成功提交一个 Run 后推进到下一个名义 occurrence。
+ worker 成功提交任一终态 Run(succeeded 或 failed)后,未暂停时推进到下一个名义 occurrence。

- Worker->>AppDB: 原子写 Session 或失败 Run、推进/暂停游标并写 webhook outbox
+ Worker->>AppDB: 原子创建 Session 与 succeeded Run,或创建 failed Run;推进/暂停游标并写 webhook outbox

Also applies to: 78-79

🧰 Tools
🪛 LanguageTool

[uncategorized] ~65-~65: 您的意思是“"不"暂停”?
Context: ...eployment 后计算下一个 occurrence,只从当前时间之后恢复,不补暂停期间的触发。worker 成功提交一个 Run 后推进到下一个名义 occu...

(BU)


[uncategorized] ~65-~65: 您的意思是“"不"齐”?
Context: ...标和启动回填后的 Job 仍由每 30 秒一次的 reconciliation 补齐,入队使用 ByArgs 保持幂等。启动回填或 reconciliatio...

(BU)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/be/deployments-api-contract.md` at line 65, 明确文档中的终态 Run 规则:每个
scheduled occurrence 都必须创建对应的 deployment_run,并为其生成 Run ID 和 webhook;创建
Session、Deployment Run 与 outbox 必须保持原子性。更新游标规则,使提交任一终态
Run(成功或非自动暂停的最终失败)后都推进到下一个 occurrence,仅自动暂停的失败停止推进。

@jh0904

jh0904 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/db/migrations/00050_schedule_deployments_with_river.sql`:
- Around line 14-28: Split the constraint and index changes into subsequent
numbered migrations: add deployment_runs_scheduled_at_check as NOT VALID, then
validate it in a later migration; create deployment_runs_schedule_occurrence_idx
and deployments_pending_schedule_idx with CREATE INDEX CONCURRENTLY in NO
TRANSACTION migrations. Update each Down path to drop the corresponding indexes,
preserving the existing constraint semantics.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b95686de-1cbb-42bd-bd64-4adc46f73f4a

📥 Commits

Reviewing files that changed from the base of the PR and between 460a222 and dade2a3.

📒 Files selected for processing (11)
  • internal/api/server.go
  • internal/config/reference_test.go
  • internal/db/agents.go
  • internal/db/db.go
  • internal/db/deployments.go
  • internal/db/migrations/00050_schedule_deployments_with_river.sql
  • internal/db/migrations_test.go
  • internal/db/webhooks.go
  • main.go
  • tests/uuid_boundary_postgres_test.go
  • web/src/features/managed-agents/resources/model.tsx
🚧 Files skipped from review as they are similar to previous changes (9)
  • web/src/features/managed-agents/resources/model.tsx
  • internal/db/db.go
  • internal/db/migrations_test.go
  • main.go
  • internal/db/agents.go
  • internal/api/server.go
  • tests/uuid_boundary_postgres_test.go
  • internal/db/webhooks.go
  • internal/db/deployments.go

Comment on lines +14 to +28
add constraint deployment_runs_scheduled_at_check check (
(trigger_type = 'schedule' and scheduled_at is not null)
or (trigger_type <> 'schedule' and scheduled_at is null)
);

create unique index deployment_runs_schedule_occurrence_idx
on deployment_runs (deployment_uuid, scheduled_at)
where trigger_type = 'schedule';

create index deployments_pending_schedule_idx
on deployments (next_scheduled_at)
where status = 'active'
and archived_at is null
and deleted_at is null
and next_scheduled_at is not null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Verify Goose transaction conventions and existing concurrent-index migrations.
rg -n -C 3 --glob '*.sql' --glob '*.go' \
  'NO TRANSACTION|CREATE( UNIQUE)? INDEX CONCURRENTLY|DROP INDEX CONCURRENTLY|VALIDATE CONSTRAINT|goose\.' \
  internal cmd/migrate/main.go main.go || true

Repository: superduck-ai/open-managed-agents

Length of output: 2852


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo '--- target migration ---'
cat -n internal/db/migrations/00050_schedule_deployments_with_river.sql

echo '--- deployment_runs/deployments schema references ---'
rg -n -C 4 'deployment_runs|deployments|scheduled_at|next_scheduled_at|trigger_type|check \(' internal/db/migrations internal/db --glob '*.sql'

echo '--- similar validate-constraint/concurrent migrations ---'
python3 - <<'PY'
from pathlib import Path
import re
for p in sorted(Path('internal/db/migrations').glob('*.sql')):
    txt=p.read_text()
    if re.search(r'CREATE\s+(UNIQUE\s+)?INDEX\s+.*CONCURRENTLY|DROP\s+INDEX\s+CONCURRENTLY|ADD\s+CONSTRAINT\s+.*NOT\s+VALID|VALIDATE\s+CONSTRAINT', txt, re.I):
        print(p)
        print(txt.splitlines()[0])
PY

Repository: superduck-ai/open-managed-agents

Length of output: 50389


🌐 Web query:

PostgreSQL CREATE INDEX CONCURRENTLY invalidates table writes indexes are built concurrently documentation

💡 Result:

No, the PostgreSQL CREATE INDEX CONCURRENTLY command does not invalidate or block table writes [1][2]. Unlike a standard index build, which acquires a lock that prevents concurrent INSERT, UPDATE, and DELETE operations on the table, CREATE INDEX CONCURRENTLY is specifically designed to allow normal DML operations (reads and writes) to continue while the index is being built [1][2][3]. Key characteristics of this process include: 1. Lock Mode: It uses a ShareUpdateExclusiveLock, which allows concurrent data modifications (inserts, updates, and deletes) while blocking only other schema-changing operations (such as ALTER TABLE or VACUUM FULL) [2][3]. 2. Multi-phase Operation: The process involves multiple table scans [1][2]. During these phases, the system ensures that changes made to the table by other transactions are correctly accounted for in the new index [2]. 3. Invalid Index State: If the command is interrupted or fails before completion, it may leave an "invalid" index behind [2][3]. Such an index does not support queries and must be manually dropped [2][3]. 4. Performance and Duration: Because the operation does not block writes, it requires more total work and takes significantly longer to complete than a standard index build [1][4]. It also consumes more CPU and I/O resources during this extended period [1][2]. 5. Finalization: At the very end of the process, the command acquires a brief AccessExclusiveLock to finalize the index status in the system catalogs, but this hold is typically very short [3]. In summary, CREATE INDEX CONCURRENTLY is the standard approach for creating indexes on live production tables without disrupting write activity, provided you can accommodate the longer build time and higher resource overhead [1][3].

Citations:


Avoid blocking table writes during migration.

The new check constraint validates rows immediately, and both indexes use normal CREATE INDEX. On live data this can stall deployment and deployment run traffic during these statements. Split these changes into new numbered migrations: add the constraint with NOT VALID; validate deployment_runs_scheduled_at_check in a later migration; create both indexes with CREATE INDEX CONCURRENTLY in NO TRANSACTION migrations; drop the normal indexes in the Down path.

🧰 Tools
🪛 Squawk (2.61.0)

[warning] 14-17: By default new constraints require a table scan and block writes to the table while that scan occurs. Use NOT VALID with a later VALIDATE CONSTRAINT call.

(constraint-missing-not-valid)


[warning] 19-21: During normal index creation, table updates are blocked, but reads are still allowed. Use concurrently to avoid blocking writes.

(require-concurrent-index-creation)


[warning] 23-28: During normal index creation, table updates are blocked, but reads are still allowed. Use concurrently to avoid blocking writes.

(require-concurrent-index-creation)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/db/migrations/00050_schedule_deployments_with_river.sql` around
lines 14 - 28, Split the constraint and index changes into subsequent numbered
migrations: add deployment_runs_scheduled_at_check as NOT VALID, then validate
it in a later migration; create deployment_runs_schedule_occurrence_idx and
deployments_pending_schedule_idx with CREATE INDEX CONCURRENTLY in NO
TRANSACTION migrations. Update each Down path to drop the corresponding indexes,
preserving the existing constraint semantics.

Sources: Coding guidelines, Linters/SAST tools

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dade2a3a66

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}

now := time.Now().UTC()
referenceFailure, err := validateRunReferences(ctx, w.database, deployment.WorkspaceUUID, deployment)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recheck environment state in the occurrence transaction

When an environment is archived after this preflight read but before ApplyScheduledOccurrence acquires its locks, the archive only updates the environment row and does not invalidate the deployment revision. The final transaction locks only the deployment and workspace, while insertSessionTx inserts the queued environment work without checking Environment.ArchivedAt; the runner likewise loads and executes that archived environment. Move the environment-state check/lock into the occurrence transaction or make environment archival atomically invalidate affected schedules.

AGENTS.md reference: AGENTS.md:L114-L116

Useful? React with 👍 / 👎.

Comment on lines +68 to +69
config.Timezone = strings.TrimSpace(config.Timezone)
location, err := time.LoadLocation(config.Timezone)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject an omitted schedule timezone

When timezone is missing or whitespace-only, trimming produces "", and Go's time.LoadLocation("") succeeds by returning UTC. Such requests are therefore accepted and persisted with an empty timezone even though the API previously required a non-empty timezone and the schedule contract requires a valid IANA name; add an explicit empty-string rejection before loading the location.

AGENTS.md reference: AGENTS.md:L114-L116

Useful? React with 👍 / 👎.

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.

1 participant