diff --git a/apps/backend/internal/db/sqlguard/exemptions.json b/apps/backend/internal/db/sqlguard/exemptions.json
index 9bff08d2e8..f734e36f24 100644
--- a/apps/backend/internal/db/sqlguard/exemptions.json
+++ b/apps/backend/internal/db/sqlguard/exemptions.json
@@ -65,6 +65,8 @@
{"file": "internal/office/repository/sqlite/base_migrations.go", "symbol": "backfillFTS", "rule": "conflict-syntax", "reason": "SQLite FTS backfill uses SQLite conflict semantics"},
{"file": "internal/office/repository/sqlite/base_migrations.go", "symbol": "runTaskPriorityRecreate", "rule": "sqlite-catalog", "reason": "SQLite-only table-recreation migration"},
{"file": "internal/office/repository/sqlite/base_migrations.go", "symbol": "tasksTableExists", "rule": "sqlite-catalog", "reason": "The Office repository is explicitly SQLite-only"},
+ {"file": "internal/office/repository/sqlite/catchup_migrations.go", "symbol": "routineTableExists", "rule": "sqlite-catalog", "reason": "The Office repository is explicitly SQLite-only"},
+ {"file": "internal/office/repository/sqlite/catchup_migrations.go", "symbol": "migrateRoutineDefaultPolicyRebuild", "rule": "sqlite-catalog", "reason": "SQLite-only table-recreation migration"},
{"file": "internal/office/repository/sqlite/dashboard.go", "symbol": "QueryRunActivity", "rule": "sqlite-date-function", "reason": "The Office repository is explicitly SQLite-only"},
{"file": "internal/office/repository/sqlite/labels.go", "symbol": "GetOrCreateLabel", "rule": "conflict-syntax", "reason": "The Office repository is explicitly SQLite-only"},
{"file": "internal/office/repository/sqlite/labels.go", "symbol": "AddLabelToTask", "rule": "conflict-syntax", "reason": "The Office repository is explicitly SQLite-only"},
@@ -107,6 +109,8 @@
{"file": "internal/agent/settings/store/sqlite.go", "symbol": "UpdateAgentProfileEnabled", "rule": "boolean-integer", "reason": "The agent settings schema stores user_modified as an INTEGER flag"},
{"file": "internal/office/configsync/store.go", "symbol": "UpsertConfigForWorkspace", "rule": "boolean-integer", "reason": "The Office config-sync schema stores last_ok as an INTEGER flag"},
{"file": "internal/office/repository/sqlite/routines.go", "symbol": "GetDueTriggers", "rule": "boolean-integer", "reason": "The Office SQLite schema stores trigger enabled as an INTEGER flag"},
+ {"file": "internal/office/repository/sqlite/routines.go", "symbol": "ListStrandedTriggers", "rule": "boolean-integer", "reason": "The Office SQLite schema stores trigger enabled as an INTEGER flag"},
+ {"file": "internal/office/repository/sqlite/routines.go", "symbol": "ReconcileTriggerNextRun", "rule": "boolean-integer", "reason": "The Office SQLite schema stores trigger enabled as an INTEGER flag"},
{"file": "internal/plugins/marketplace/sourcestore.go", "symbol": "EnsureBuiltin", "rule": "boolean-integer", "reason": "The marketplace source schema stores enabled and builtin as INTEGER flags"},
{"file": "internal/plugins/state/instance_store.go", "symbol": "List", "rule": "boolean-integer", "reason": "The plugin instance-state schema stores deleted as an INTEGER tombstone"},
{"file": "internal/plugins/state/instance_store.go", "symbol": "Set", "rule": "boolean-integer", "reason": "The plugin instance-state schema stores deleted as an INTEGER tombstone"},
diff --git a/apps/backend/internal/office/AGENTS.md b/apps/backend/internal/office/AGENTS.md
index 63a42a8cc8..a0cbf11c69 100644
--- a/apps/backend/internal/office/AGENTS.md
+++ b/apps/backend/internal/office/AGENTS.md
@@ -4,7 +4,7 @@
## Spec authority
-`docs/specs/office/` (15 files, plus later requirements/system-design additions below) is the authority for what Office is and why — it outranks any card, register, or comment. **Where code and spec disagree, that is a defect in one of them; do not silently follow the code.** A sixteenth office-tagged spec, [per-agent + per-role tier selection](../../../../docs/specs/office-agent-tier-routing/spec.md), lives in a sibling directory, not under `docs/specs/office/`.
+`docs/specs/office/` (16 files, plus later requirements/system-design additions below) is the authority for what Office is and why — it outranks any card, register, or comment. **Where code and spec disagree, that is a defect in one of them; do not silently follow the code.** A seventeenth office-tagged spec, [per-agent + per-role tier selection](../../../../docs/specs/office-agent-tier-routing/spec.md), lives in a sibling directory, not under `docs/specs/office/`.
| Spec | Status | Covers |
|---|---|---|
@@ -12,6 +12,7 @@
| `agents.md` | draft | Persistent Office agent identity vs. execution profiles |
| `tasks.md` | draft | Office task model: assignee, reviewers/approvers, blockers, subtasks, per-agent working memory |
| `scheduler.md` | draft | Autonomous wakeup pipeline: assignments/comments/approvals, routines, idle skip, retry/backoff |
+| `routine-catch-up.md` | shipped | Resuming after downtime produces exactly one run per due trigger, never one per missed tick; `catch_up_max` bounds only how many ticks are counted/reported, never how many runs fire; policy `enqueue_missed_with_cap` renamed to `summarize_missed` (deprecated alias accepted forever) |
| `runtime.md` | draft | Error-handling contract for the agent runtime; **2026-08-17 amendment**: provider classification/recovery is superseded by `../platform/provider-error-recovery.md` and `../agents/dynamic-agent-routing.md` — read the amendment banner before the body |
| `routing.md` | **archived** | Provider routing; superseded by `../agents/dynamic-agent-routing.md` — do not treat as current |
| `costs.md` | in-progress | Cost tracking and budget management |
diff --git a/apps/backend/internal/office/models/catchup.go b/apps/backend/internal/office/models/catchup.go
new file mode 100644
index 0000000000..7ec518345e
--- /dev/null
+++ b/apps/backend/internal/office/models/catchup.go
@@ -0,0 +1,58 @@
+package models
+
+import "fmt"
+
+// NormaliseCatchUpPolicy maps a raw stored or request value onto the
+// canonical RoutineCatchUpPolicy set. The deprecated alias, an empty
+// value, and any unrecognized value all normalize to the summarizing
+// policy — the same "fail open toward reporting" default the write
+// funnel and the read-side Scan hook below share.
+func NormaliseCatchUpPolicy(raw string) RoutineCatchUpPolicy {
+ if RoutineCatchUpPolicy(raw) == CatchUpPolicySkipMissed {
+ return CatchUpPolicySkipMissed
+ }
+ return CatchUpPolicySummarizeMissed
+}
+
+// Scan implements sql.Scanner so every SELECT into a RoutineCatchUpPolicy
+// field normalizes the deprecated alias and any unrecognized stored
+// value to the canonical one on the way out of the database. This is
+// the single read-side chokepoint: GetRoutine and ListRoutines are
+// SELECT * through sqlx StructScan with no other post-scan hook.
+func (p *RoutineCatchUpPolicy) Scan(value any) error {
+ switch v := value.(type) {
+ case nil:
+ *p = NormaliseCatchUpPolicy("")
+ case string:
+ *p = NormaliseCatchUpPolicy(v)
+ case []byte:
+ *p = NormaliseCatchUpPolicy(string(v))
+ default:
+ return fmt.Errorf("RoutineCatchUpPolicy: unsupported scan type %T", value)
+ }
+ return nil
+}
+
+// Bounds a stored or requested catch_up_max is clamped to.
+// CatchUpMaxDefault is the floor a value below 1 is raised to;
+// CatchUpMaxCeiling is the ceiling a value above it is lowered to.
+// Named constants so both bounds exist in exactly one place in Go.
+const (
+ CatchUpMaxDefault = 25
+ CatchUpMaxCeiling = 1000
+)
+
+// NormaliseCatchUpMax clamps v to [1, CatchUpMaxCeiling], mapping a
+// value below 1 to CatchUpMaxDefault. A fixpoint: normalizing an
+// already-normalized value returns it unchanged, so applying it more
+// than once (write funnel plus a belt-and-braces use-time clamp) is
+// always safe.
+func NormaliseCatchUpMax(v int) int {
+ if v < 1 {
+ return CatchUpMaxDefault
+ }
+ if v > CatchUpMaxCeiling {
+ return CatchUpMaxCeiling
+ }
+ return v
+}
diff --git a/apps/backend/internal/office/models/catchup_test.go b/apps/backend/internal/office/models/catchup_test.go
new file mode 100644
index 0000000000..0d44dc3fb8
--- /dev/null
+++ b/apps/backend/internal/office/models/catchup_test.go
@@ -0,0 +1,109 @@
+package models_test
+
+import (
+ "testing"
+
+ "github.com/kandev/kandev/internal/office/models"
+)
+
+func TestNormaliseCatchUpPolicy(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want models.RoutineCatchUpPolicy
+ }{
+ {"canonical", "summarize_missed", models.CatchUpPolicySummarizeMissed},
+ {"deprecated alias", "enqueue_missed_with_cap", models.CatchUpPolicySummarizeMissed},
+ {"skip_missed unchanged", "skip_missed", models.CatchUpPolicySkipMissed},
+ {"empty defaults to summarize", "", models.CatchUpPolicySummarizeMissed},
+ {"unknown defaults to summarize", "garbage", models.CatchUpPolicySummarizeMissed},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := models.NormaliseCatchUpPolicy(tc.in); got != tc.want {
+ t.Errorf("NormaliseCatchUpPolicy(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestRoutineCatchUpPolicy_Scan(t *testing.T) {
+ cases := []struct {
+ name string
+ in any
+ want models.RoutineCatchUpPolicy
+ }{
+ {"string alias", "enqueue_missed_with_cap", models.CatchUpPolicySummarizeMissed},
+ {"bytes alias", []byte("enqueue_missed_with_cap"), models.CatchUpPolicySummarizeMissed},
+ {"string canonical", "summarize_missed", models.CatchUpPolicySummarizeMissed},
+ {"string skip", "skip_missed", models.CatchUpPolicySkipMissed},
+ {"nil", nil, models.CatchUpPolicySummarizeMissed},
+ {"unknown string", "totally-unknown", models.CatchUpPolicySummarizeMissed},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var p models.RoutineCatchUpPolicy
+ if err := p.Scan(tc.in); err != nil {
+ t.Fatalf("Scan(%v): %v", tc.in, err)
+ }
+ if p != tc.want {
+ t.Errorf("Scan(%v) = %q, want %q", tc.in, p, tc.want)
+ }
+ })
+ }
+}
+
+func TestRoutineCatchUpPolicy_Scan_UnsupportedType(t *testing.T) {
+ var p models.RoutineCatchUpPolicy
+ if err := p.Scan(42); err == nil {
+ t.Fatal("Scan(42) error = nil, want error for unsupported scan type")
+ }
+}
+
+func TestRoutineCatchUpPolicy_Valid(t *testing.T) {
+ valid := []models.RoutineCatchUpPolicy{
+ models.CatchUpPolicySummarizeMissed,
+ models.CatchUpPolicyEnqueueMissedWithCap,
+ models.CatchUpPolicySkipMissed,
+ }
+ for _, p := range valid {
+ if !p.Valid() {
+ t.Errorf("Valid(%q) = false, want true", p)
+ }
+ }
+ if models.RoutineCatchUpPolicy("bogus").Valid() {
+ t.Error("Valid(bogus) = true, want false")
+ }
+}
+
+func TestNormaliseCatchUpMax(t *testing.T) {
+ cases := []struct {
+ name string
+ in int
+ want int
+ }{
+ {"in range untouched", 5, 5},
+ {"zero clamps to default", 0, models.CatchUpMaxDefault},
+ {"negative clamps to default", -3, models.CatchUpMaxDefault},
+ {"exactly one untouched", 1, 1},
+ {"exactly ceiling untouched", 1000, 1000},
+ {"above ceiling clamps down", 5000, models.CatchUpMaxCeiling},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := models.NormaliseCatchUpMax(tc.in); got != tc.want {
+ t.Errorf("NormaliseCatchUpMax(%d) = %d, want %d", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestNormaliseCatchUpMax_Fixpoint(t *testing.T) {
+ for _, in := range []int{-5, 0, 1, 25, 1000, 5000} {
+ once := models.NormaliseCatchUpMax(in)
+ twice := models.NormaliseCatchUpMax(once)
+ if once != twice {
+ t.Errorf("NormaliseCatchUpMax not a fixpoint at %d: once=%d twice=%d", in, once, twice)
+ }
+ }
+}
diff --git a/apps/backend/internal/office/models/enums.go b/apps/backend/internal/office/models/enums.go
index bf69d8c96d..232b104d43 100644
--- a/apps/backend/internal/office/models/enums.go
+++ b/apps/backend/internal/office/models/enums.go
@@ -84,12 +84,25 @@ func (p RoutineConcurrencyPolicy) Valid() bool {
}
// RoutineCatchUpPolicy controls what happens when a scheduled routine
-// missed fires (e.g. backend was down).
+// missed fires (e.g. backend was down). Whatever the policy, a claim
+// dispatches at most one routine run — the policy only decides whether
+// the gap crossed is reported to the woken agent. See
+// docs/specs/office/requirements/routine-catch-up.md.
type RoutineCatchUpPolicy string
// Routine catch-up policy values. The DB default is
-// `enqueue_missed_with_cap`; the alternative skips missed fires entirely.
+// `summarize_missed`; the alternative skips gap reporting entirely.
const (
+ // CatchUpPolicySummarizeMissed is the canonical summarizing policy:
+ // the gap crossed by a claim is recorded and delivered to the woken
+ // agent. Named for what happens (one wake, gap summarized), not for
+ // the discarded enqueue-N-runs design the old name implied.
+ CatchUpPolicySummarizeMissed RoutineCatchUpPolicy = "summarize_missed"
+ // CatchUpPolicyEnqueueMissedWithCap is the deprecated alias for
+ // CatchUpPolicySummarizeMissed. Retained forever — never delete —
+ // so NormaliseCatchUpPolicy has a name to compare persisted rows
+ // and API requests against; installs that already stored it must
+ // keep reading and writing successfully.
CatchUpPolicyEnqueueMissedWithCap RoutineCatchUpPolicy = "enqueue_missed_with_cap"
CatchUpPolicySkipMissed RoutineCatchUpPolicy = "skip_missed"
)
@@ -97,10 +110,13 @@ const (
// String implements fmt.Stringer.
func (p RoutineCatchUpPolicy) String() string { return string(p) }
-// Valid reports whether p is one of the declared RoutineCatchUpPolicy values.
+// Valid reports whether p is one of the declared RoutineCatchUpPolicy
+// values, including the deprecated alias — a client sending the old
+// value on create/update gets 200, not 400; NormaliseCatchUpPolicy
+// normalizes the persisted value to the canonical one.
func (p RoutineCatchUpPolicy) Valid() bool {
switch p {
- case CatchUpPolicyEnqueueMissedWithCap, CatchUpPolicySkipMissed:
+ case CatchUpPolicySummarizeMissed, CatchUpPolicyEnqueueMissedWithCap, CatchUpPolicySkipMissed:
return true
}
return false
diff --git a/apps/backend/internal/office/models/models.go b/apps/backend/internal/office/models/models.go
index 8e3a81398d..64df520228 100644
--- a/apps/backend/internal/office/models/models.go
+++ b/apps/backend/internal/office/models/models.go
@@ -604,19 +604,29 @@ type RoutineTrigger struct {
}
// RoutineRun represents a single run of a routine.
+//
+// CatchUpMissedTicks/CatchUpFirstMissedAt/CatchUpTruncated are the gap
+// summary measured for the claim that created this run — written once
+// at creation and never modified afterward (AC-OFFICE-ROUTINE-CATCHUP-002.10).
+// CatchUpMissedTicks is nil when no gap summary was recorded; per
+// AC-002.3, absence is never represented as a stored zero, so a nil
+// check (not a zero check) is the presence test.
type RoutineRun struct {
- ID string `json:"id" db:"id"`
- RoutineID string `json:"routine_id" db:"routine_id"`
- TriggerID string `json:"trigger_id" db:"trigger_id"`
- Source string `json:"source" db:"source"`
- Status RoutineRunStatus `json:"status" db:"status"`
- TriggerPayload string `json:"trigger_payload" db:"trigger_payload"`
- LinkedTaskID string `json:"linked_task_id" db:"linked_task_id"`
- CoalescedIntoRunID string `json:"coalesced_into_run_id" db:"coalesced_into_run_id"`
- DispatchFingerprint string `json:"dispatch_fingerprint" db:"dispatch_fingerprint"`
- StartedAt *time.Time `json:"started_at" db:"started_at"`
- CompletedAt *time.Time `json:"completed_at" db:"completed_at"`
- CreatedAt time.Time `json:"created_at" db:"created_at"`
+ ID string `json:"id" db:"id"`
+ RoutineID string `json:"routine_id" db:"routine_id"`
+ TriggerID string `json:"trigger_id" db:"trigger_id"`
+ Source string `json:"source" db:"source"`
+ Status RoutineRunStatus `json:"status" db:"status"`
+ TriggerPayload string `json:"trigger_payload" db:"trigger_payload"`
+ LinkedTaskID string `json:"linked_task_id" db:"linked_task_id"`
+ CoalescedIntoRunID string `json:"coalesced_into_run_id" db:"coalesced_into_run_id"`
+ DispatchFingerprint string `json:"dispatch_fingerprint" db:"dispatch_fingerprint"`
+ CatchUpMissedTicks *int `json:"catch_up_missed_ticks,omitempty" db:"catch_up_missed_ticks"`
+ CatchUpFirstMissedAt *time.Time `json:"catch_up_first_missed_at,omitempty" db:"catch_up_first_missed_at"`
+ CatchUpTruncated bool `json:"catch_up_truncated" db:"catch_up_truncated"`
+ StartedAt *time.Time `json:"started_at" db:"started_at"`
+ CompletedAt *time.Time `json:"completed_at" db:"completed_at"`
+ CreatedAt time.Time `json:"created_at" db:"created_at"`
// SkipReason carries exactly one value today, "workspace_paused",
// distinguishing a workspace-pause skip from every other skip cause
// (e.g. skip_if_active concurrency). Empty for a run not skipped by a
diff --git a/apps/backend/internal/office/repository/sqlite/base.go b/apps/backend/internal/office/repository/sqlite/base.go
index 2e5b18498a..124709caad 100644
--- a/apps/backend/internal/office/repository/sqlite/base.go
+++ b/apps/backend/internal/office/repository/sqlite/base.go
@@ -507,7 +507,7 @@ func (r *Repository) createRoutineTables() error {
assignee_agent_profile_id TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
concurrency_policy TEXT DEFAULT 'skip_if_active',
- catch_up_policy TEXT NOT NULL DEFAULT 'enqueue_missed_with_cap',
+ catch_up_policy TEXT NOT NULL DEFAULT 'summarize_missed',
catch_up_max INTEGER NOT NULL DEFAULT 25,
variables TEXT DEFAULT '{}',
last_run_at TIMESTAMP,
@@ -542,6 +542,9 @@ func (r *Repository) createRoutineTables() error {
linked_task_id TEXT DEFAULT '',
coalesced_into_run_id TEXT DEFAULT '',
dispatch_fingerprint TEXT DEFAULT '',
+ catch_up_missed_ticks INTEGER,
+ catch_up_first_missed_at TIMESTAMP,
+ catch_up_truncated INTEGER NOT NULL DEFAULT 0,
started_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
diff --git a/apps/backend/internal/office/repository/sqlite/base_migrations.go b/apps/backend/internal/office/repository/sqlite/base_migrations.go
index 968b8e9e7a..4d92465b6e 100644
--- a/apps/backend/internal/office/repository/sqlite/base_migrations.go
+++ b/apps/backend/internal/office/repository/sqlite/base_migrations.go
@@ -63,6 +63,7 @@ func (r *Repository) runMigrations() error {
r.migrateWakeWaveColumns()
r.migrate.Apply("task_workspace_groups.ownership_generation",
`ALTER TABLE task_workspace_groups ADD COLUMN ownership_generation INTEGER NOT NULL DEFAULT 1`)
+ r.migrateRoutineCatchUp()
r.migrateBudgetPolicyRevision()
r.migrateWorkspacePauseSkipAttribution()
r.migrateLoopLivenessCausationID()
diff --git a/apps/backend/internal/office/repository/sqlite/catchup_migration_replay_postgres_test.go b/apps/backend/internal/office/repository/sqlite/catchup_migration_replay_postgres_test.go
new file mode 100644
index 0000000000..17f7812dcc
--- /dev/null
+++ b/apps/backend/internal/office/repository/sqlite/catchup_migration_replay_postgres_test.go
@@ -0,0 +1,84 @@
+package sqlite_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/kandev/kandev/internal/office/repository/sqlite"
+ taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite"
+ "github.com/kandev/kandev/internal/testutil"
+)
+
+// TestMigrate_RoutineCatchUpPolicyDefaultRebuildPostgres is the Postgres
+// counterpart to TestMigrate_RoutineCatchUpPolicyDefaultRebuild
+// (catchup_migration_replay_test.go). The SQLite migration detects
+// staleness via sqlite_master/PRAGMA table_info, neither of which exist on
+// Postgres, so without a dedicated Postgres path the rebuild silently never
+// runs there and an upgraded Postgres install's office_routines.catch_up_policy
+// column keeps the retired 'enqueue_missed_with_cap' DEFAULT forever
+// (AC-OFFICE-ROUTINE-CATCHUP-003.3: "the column's stored default shall be
+// summarize_missed on an upgraded install ... Inspecting the live schema
+// ... shall not show the retired value").
+//
+// Skips unless KANDEV_TEST_POSTGRES_DSN is set.
+func TestMigrate_RoutineCatchUpPolicyDefaultRebuildPostgres(t *testing.T) {
+ db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
+
+ // office_routines' schema init runs after the task repository's, mirroring
+ // production boot order (see runs_inflight_postgres_test.go).
+ if _, err := taskrepo.NewWithDB(db, db, nil); err != nil {
+ t.Fatalf("init task repo: %v", err)
+ }
+
+ // Fresh init creates office_routines with the current (correct) default.
+ if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
+ t.Fatalf("init office repo (fresh schema): %v", err)
+ }
+
+ // Simulate an upgraded install: force the column's DEFAULT back to the
+ // retired literal, as if this schema predated the rename.
+ if _, err := db.Exec(
+ `ALTER TABLE office_routines ALTER COLUMN catch_up_policy SET DEFAULT 'enqueue_missed_with_cap'`,
+ ); err != nil {
+ t.Fatalf("seed legacy default: %v", err)
+ }
+
+ // Re-opening the repository must detect and correct the stale default.
+ if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
+ t.Fatalf("init office repo (replay migrations): %v", err)
+ }
+
+ dflt := currentCatchUpPolicyDefault(t, db)
+ if strings.Contains(dflt, "enqueue_missed_with_cap") {
+ t.Fatalf("catch_up_policy DEFAULT still names the retired value: %q", dflt)
+ }
+ if !strings.Contains(dflt, "summarize_missed") {
+ t.Fatalf("catch_up_policy DEFAULT = %q, want it to contain summarize_missed", dflt)
+ }
+
+ // Idempotency: a second replay against an already-correct default must
+ // not error, and must leave it unchanged.
+ if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
+ t.Fatalf("second init should be idempotent: %v", err)
+ }
+ if got := currentCatchUpPolicyDefault(t, db); !strings.Contains(got, "summarize_missed") {
+ t.Fatalf("catch_up_policy DEFAULT after replay = %q, want it to contain summarize_missed", got)
+ }
+}
+
+func currentCatchUpPolicyDefault(t *testing.T, db interface {
+ Get(dest interface{}, query string, args ...interface{}) error
+}) string {
+ t.Helper()
+ var dflt string
+ if err := db.Get(&dflt, `
+ SELECT column_default
+ FROM information_schema.columns
+ WHERE table_schema = current_schema()
+ AND table_name = 'office_routines'
+ AND column_name = 'catch_up_policy'
+ `); err != nil {
+ t.Fatalf("read column_default: %v", err)
+ }
+ return dflt
+}
diff --git a/apps/backend/internal/office/repository/sqlite/catchup_migration_replay_test.go b/apps/backend/internal/office/repository/sqlite/catchup_migration_replay_test.go
new file mode 100644
index 0000000000..a8d00fd9df
--- /dev/null
+++ b/apps/backend/internal/office/repository/sqlite/catchup_migration_replay_test.go
@@ -0,0 +1,235 @@
+package sqlite_test
+
+import (
+ "database/sql"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jmoiron/sqlx"
+ _ "github.com/mattn/go-sqlite3"
+
+ "github.com/kandev/kandev/internal/office/repository/sqlite"
+)
+
+// TestMigrate_RoutineCatchUpPolicyDefaultRebuild seeds a legacy
+// office_routines table (as it existed before this change: DEFAULT
+// 'enqueue_missed_with_cap', no gap-summary columns on office_routine_runs)
+// with a routine, a trigger, and a run already attached, then asserts the
+// office repo migration:
+// - rewrites the stored DEFAULT to 'summarize_missed' (AC-003.3, visible
+// on the live schema of an upgraded install, not just in a fresh one)
+// - normalizes the pre-existing 'enqueue_missed_with_cap' row value
+// - does not cascade-delete the routine's trigger or run rows, which is
+// the hazard PRAGMA foreign_keys=OFF exists to avoid during the
+// drop+rename table-rebuild dance
+// - preserves every column and timestamp on the surviving rows
+func TestMigrate_RoutineCatchUpPolicyDefaultRebuild(t *testing.T) {
+ dbPath := t.TempDir() + "/test.db?_journal_mode=WAL&_foreign_keys=on"
+ db, err := sqlx.Open("sqlite3", dbPath)
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ createdAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ if _, err := db.Exec(`
+ CREATE TABLE office_routines (
+ id TEXT PRIMARY KEY,
+ workspace_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT DEFAULT '',
+ task_template TEXT NOT NULL DEFAULT '{}',
+ assignee_agent_profile_id TEXT DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'active',
+ concurrency_policy TEXT DEFAULT 'skip_if_active',
+ catch_up_policy TEXT NOT NULL DEFAULT 'enqueue_missed_with_cap',
+ catch_up_max INTEGER NOT NULL DEFAULT 25,
+ variables TEXT DEFAULT '{}',
+ last_run_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL
+ );
+ CREATE TABLE office_routine_triggers (
+ id TEXT PRIMARY KEY,
+ routine_id TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ cron_expression TEXT DEFAULT '',
+ timezone TEXT DEFAULT '',
+ public_id TEXT DEFAULT '',
+ signing_mode TEXT DEFAULT '',
+ secret TEXT DEFAULT '',
+ next_run_at TIMESTAMP,
+ last_fired_at TIMESTAMP,
+ enabled INTEGER DEFAULT 1,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL,
+ FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE
+ );
+ CREATE TABLE office_routine_runs (
+ id TEXT PRIMARY KEY,
+ routine_id TEXT NOT NULL,
+ trigger_id TEXT DEFAULT '',
+ source TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'received',
+ trigger_payload TEXT DEFAULT '{}',
+ linked_task_id TEXT DEFAULT '',
+ coalesced_into_run_id TEXT DEFAULT '',
+ dispatch_fingerprint TEXT DEFAULT '',
+ started_at TIMESTAMP,
+ completed_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL,
+ FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE
+ );
+ `); err != nil {
+ t.Fatalf("seed legacy schema: %v", err)
+ }
+
+ if _, err := db.Exec(`
+ INSERT INTO office_routines (
+ id, workspace_id, name, description, task_template,
+ assignee_agent_profile_id, status, concurrency_policy,
+ catch_up_policy, catch_up_max, variables, last_run_at, created_at, updated_at
+ ) VALUES (
+ 'routine-legacy', 'ws-1', 'Legacy routine', 'desc', '{}',
+ 'agent-1', 'active', 'skip_if_active',
+ 'enqueue_missed_with_cap', 25, '{}', NULL, ?, ?
+ )`, createdAt, createdAt); err != nil {
+ t.Fatalf("seed legacy routine: %v", err)
+ }
+ if _, err := db.Exec(`
+ INSERT INTO office_routines (
+ id, workspace_id, name, description, task_template,
+ assignee_agent_profile_id, status, concurrency_policy,
+ catch_up_policy, catch_up_max, variables, last_run_at, created_at, updated_at
+ ) VALUES (
+ 'routine-max-below-floor', 'ws-1', 'Below floor', 'desc', '{}',
+ 'agent-1', 'active', 'skip_if_active',
+ 'summarize_missed', 0, '{}', NULL, ?, ?
+ )`, createdAt, createdAt); err != nil {
+ t.Fatalf("seed below-floor routine: %v", err)
+ }
+ if _, err := db.Exec(`
+ INSERT INTO office_routines (
+ id, workspace_id, name, description, task_template,
+ assignee_agent_profile_id, status, concurrency_policy,
+ catch_up_policy, catch_up_max, variables, last_run_at, created_at, updated_at
+ ) VALUES (
+ 'routine-max-above-ceiling', 'ws-1', 'Above ceiling', 'desc', '{}',
+ 'agent-1', 'active', 'skip_if_active',
+ 'summarize_missed', 5000, '{}', NULL, ?, ?
+ )`, createdAt, createdAt); err != nil {
+ t.Fatalf("seed above-ceiling routine: %v", err)
+ }
+ if _, err := db.Exec(`
+ INSERT INTO office_routine_triggers (
+ id, routine_id, kind, cron_expression, timezone, enabled, created_at, updated_at
+ ) VALUES ('trigger-legacy', 'routine-legacy', 'cron', '* * * * *', 'UTC', 1, ?, ?)
+ `, createdAt, createdAt); err != nil {
+ t.Fatalf("seed legacy trigger: %v", err)
+ }
+ if _, err := db.Exec(`
+ INSERT INTO office_routine_runs (
+ id, routine_id, trigger_id, source, status, trigger_payload, created_at
+ ) VALUES ('run-legacy', 'routine-legacy', 'trigger-legacy', 'cron', 'received', '{}', ?)
+ `, createdAt); err != nil {
+ t.Fatalf("seed legacy run: %v", err)
+ }
+
+ if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
+ t.Fatalf("init office repo (run migrations): %v", err)
+ }
+
+ // The stored DEFAULT clause itself must now read summarize_missed.
+ rows, err := db.Queryx(`PRAGMA table_info(office_routines)`)
+ if err != nil {
+ t.Fatalf("pragma table_info: %v", err)
+ }
+ defer func() { _ = rows.Close() }()
+ dfltFound := false
+ for rows.Next() {
+ var cid int
+ var name, ctype string
+ var notnull, pk int
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
+ t.Fatalf("scan pragma: %v", err)
+ }
+ if name == "catch_up_policy" {
+ dfltFound = true
+ if strings.Contains(dflt.String, "enqueue_missed_with_cap") {
+ t.Fatalf("catch_up_policy DEFAULT still names the retired value: %q", dflt.String)
+ }
+ if !strings.Contains(dflt.String, "summarize_missed") {
+ t.Fatalf("catch_up_policy DEFAULT = %q, want it to contain summarize_missed", dflt.String)
+ }
+ }
+ }
+ if !dfltFound {
+ t.Fatal("catch_up_policy column not found after migration")
+ }
+
+ // The pre-existing row's value is normalized.
+ var policy string
+ var routineCreatedAt, routineUpdatedAt time.Time
+ if err := db.QueryRow(
+ `SELECT catch_up_policy, created_at, updated_at FROM office_routines WHERE id = 'routine-legacy'`,
+ ).Scan(&policy, &routineCreatedAt, &routineUpdatedAt); err != nil {
+ t.Fatalf("select migrated routine: %v", err)
+ }
+ if policy != "summarize_missed" {
+ t.Errorf("routine-legacy catch_up_policy = %q, want summarize_missed", policy)
+ }
+ if !routineCreatedAt.Equal(createdAt) {
+ t.Errorf("routine-legacy created_at = %v, want %v (preserved across rebuild)", routineCreatedAt, createdAt)
+ }
+
+ // catch_up_max out-of-range values are normalized once on upgrade
+ // (AC-OFFICE-ROUTINE-CATCHUP-001.13): below the floor snaps to the
+ // default, above the ceiling clamps down to it.
+ var belowFloorMax, aboveCeilingMax int
+ if err := db.QueryRow(
+ `SELECT catch_up_max FROM office_routines WHERE id = 'routine-max-below-floor'`,
+ ).Scan(&belowFloorMax); err != nil {
+ t.Fatalf("select below-floor routine: %v", err)
+ }
+ if belowFloorMax != 25 {
+ t.Errorf("routine-max-below-floor catch_up_max = %d, want 25 (default floor)", belowFloorMax)
+ }
+ if err := db.QueryRow(
+ `SELECT catch_up_max FROM office_routines WHERE id = 'routine-max-above-ceiling'`,
+ ).Scan(&aboveCeilingMax); err != nil {
+ t.Fatalf("select above-ceiling routine: %v", err)
+ }
+ if aboveCeilingMax != 1000 {
+ t.Errorf("routine-max-above-ceiling catch_up_max = %d, want 1000 (ceiling)", aboveCeilingMax)
+ }
+
+ // The trigger and run rows must survive the drop+rename — this is the
+ // cascade PRAGMA foreign_keys=OFF exists to prevent.
+ var triggerCount, runCount int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM office_routine_triggers WHERE id = 'trigger-legacy'`).Scan(&triggerCount); err != nil {
+ t.Fatalf("count trigger: %v", err)
+ }
+ if triggerCount != 1 {
+ t.Errorf("trigger-legacy survived = %d rows, want 1 (cascaded away by the rebuild)", triggerCount)
+ }
+ if err := db.QueryRow(`SELECT COUNT(*) FROM office_routine_runs WHERE id = 'run-legacy'`).Scan(&runCount); err != nil {
+ t.Fatalf("count run: %v", err)
+ }
+ if runCount != 1 {
+ t.Errorf("run-legacy survived = %d rows, want 1 (cascaded away by the rebuild)", runCount)
+ }
+
+ // Re-running the migration is a no-op: the guard sees a non-stale
+ // default and skips the rebuild.
+ if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
+ t.Fatalf("second init should be idempotent: %v", err)
+ }
+ if err := db.QueryRow(`SELECT COUNT(*) FROM office_routines WHERE id = 'routine-legacy'`).Scan(&runCount); err != nil {
+ t.Fatalf("count routine after replay: %v", err)
+ }
+ if runCount != 1 {
+ t.Errorf("routine-legacy count after replay = %d, want 1", runCount)
+ }
+}
diff --git a/apps/backend/internal/office/repository/sqlite/catchup_migrations.go b/apps/backend/internal/office/repository/sqlite/catchup_migrations.go
new file mode 100644
index 0000000000..f2b3112452
--- /dev/null
+++ b/apps/backend/internal/office/repository/sqlite/catchup_migrations.go
@@ -0,0 +1,188 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+
+ "go.uber.org/zap"
+
+ "github.com/kandev/kandev/internal/office/models"
+)
+
+// migrateRoutineCatchUp applies every schema and data migration for the
+// routine catch-up capability (docs/specs/office/requirements/routine-catch-up.md):
+// the three gap-summary columns on office_routine_runs, the deprecated
+// catch_up_policy alias value on existing rows, the catch_up_max bound
+// clamps, and the office_routines table rebuild that fixes the column's
+// stored DEFAULT for upgraded installs (AC-003.3).
+func (r *Repository) migrateRoutineCatchUp() {
+ _ = r.migrate.Apply("office_routine_runs.catch_up_missed_ticks",
+ `ALTER TABLE office_routine_runs ADD COLUMN catch_up_missed_ticks INTEGER`)
+ _ = r.migrate.Apply("office_routine_runs.catch_up_first_missed_at",
+ `ALTER TABLE office_routine_runs ADD COLUMN catch_up_first_missed_at TIMESTAMP`)
+ _ = r.migrate.Apply("office_routine_runs.catch_up_truncated",
+ `ALTER TABLE office_routine_runs ADD COLUMN catch_up_truncated INTEGER NOT NULL DEFAULT 0`)
+
+ _ = r.migrate.Apply("office_routines.catch_up_policy_rename",
+ fmt.Sprintf(
+ `UPDATE office_routines SET catch_up_policy = '%s' WHERE catch_up_policy = '%s'`,
+ models.CatchUpPolicySummarizeMissed, models.CatchUpPolicyEnqueueMissedWithCap,
+ ))
+ _ = r.migrate.Apply("office_routines.catch_up_max_floor",
+ fmt.Sprintf(`UPDATE office_routines SET catch_up_max = %d WHERE catch_up_max < 1`, models.CatchUpMaxDefault))
+ _ = r.migrate.Apply("office_routines.catch_up_max_ceiling",
+ fmt.Sprintf(`UPDATE office_routines SET catch_up_max = %d WHERE catch_up_max > %d`,
+ models.CatchUpMaxCeiling, models.CatchUpMaxCeiling))
+
+ if err := r.migrateRoutineDefaultPolicyRebuild(); err != nil {
+ if r.log != nil {
+ r.log.Warn("routine catch-up default-policy rebuild failed", zap.Error(err))
+ } else {
+ fmt.Println("office sqlite migrate routine catch-up default policy:", err)
+ }
+ return
+ }
+ if r.log != nil {
+ r.log.Info("migration applied", zap.String("name", "office_routines.catch_up_policy_default_rebuild"))
+ }
+
+ // migrateRoutineDefaultPolicyRebuild above only ever fires on SQLite
+ // (its staleness probe queries sqlite_master/PRAGMA table_info, which
+ // don't exist on Postgres); this covers the same DEFAULT correction on
+ // Postgres, which needs no table rebuild — see
+ // migrateRoutineDefaultPolicyRebuildPostgres.
+ r.applyRoutineDefaultPolicyRebuildPostgres()
+}
+
+// routineTableExists returns true when office_routines is present. Mirrors
+// tasksTableExists's shape for the same reason: a minimal single-domain
+// test repository may not have created it yet.
+func (r *Repository) routineTableExists() bool {
+ var exists int
+ err := r.db.QueryRow(
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='office_routines'",
+ ).Scan(&exists)
+ return err == nil && exists == 1
+}
+
+// routineCatchUpPolicyDefaultIsStale reports whether office_routines'
+// catch_up_policy column still carries the retired literal as its stored
+// DEFAULT. SQLite has no ALTER COLUMN ... SET DEFAULT, so this is the guard
+// that makes migrateRoutineDefaultPolicyRebuild's table-recreate idempotent
+// — it only runs while the live schema disagrees.
+func (r *Repository) routineCatchUpPolicyDefaultIsStale() bool {
+ rows, err := r.db.Queryx(`PRAGMA table_info(office_routines)`)
+ if err != nil {
+ return false
+ }
+ defer func() { _ = rows.Close() }()
+ for rows.Next() {
+ var cid int
+ var name, ctype string
+ var notnull, pk int
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
+ return false
+ }
+ if name != "catch_up_policy" {
+ continue
+ }
+ return strings.Contains(dflt.String, string(models.CatchUpPolicyEnqueueMissedWithCap))
+ }
+ return false
+}
+
+// migrateRoutineDefaultPolicyRebuild recreates office_routines with a
+// corrected catch_up_policy DEFAULT ('summarize_missed'), following the
+// table-rebuild pattern runTaskPriorityRecreate already establishes in this
+// package: acquire a dedicated connection so PRAGMA and the DDL share one
+// SQLite session, disable foreign keys for the duration (office_routine_runs
+// and office_routine_triggers both declare
+// ON DELETE CASCADE REFERENCES office_routines(id), and dropping the old
+// table with enforcement on would cascade every routine run and trigger out
+// of existence), then create/copy/drop/rename. Every column is mirrored
+// verbatim; only the DEFAULT clause changes. The CREATE/INSERT/DROP/RENAME
+// sequence runs inside one transaction (unlike runTaskPriorityRecreate's
+// autocommitted steps): SQLite fully supports DDL inside a transaction, and
+// this table is dropped and recreated in place rather than alongside a
+// still-present sibling, so a crash between DROP and RENAME with no
+// transaction would leave neither the original nor the renamed table behind.
+func (r *Repository) migrateRoutineDefaultPolicyRebuild() error {
+ if !r.routineTableExists() {
+ return nil
+ }
+ if !r.routineCatchUpPolicyDefaultIsStale() {
+ return nil
+ }
+
+ ctx := context.Background()
+ conn, err := r.db.Conn(ctx)
+ if err != nil {
+ return fmt.Errorf("acquire migration connection: %w", err)
+ }
+ defer func() { _ = conn.Close() }()
+
+ if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys=OFF`); err != nil {
+ return fmt.Errorf("disable foreign keys: %w", err)
+ }
+ defer func() { _, _ = conn.ExecContext(ctx, `PRAGMA foreign_keys=ON`) }()
+
+ tx, err := conn.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin routine default-policy migration transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ for _, stmt := range routineDefaultPolicyMigrationStatements() {
+ if _, err := tx.ExecContext(ctx, stmt); err != nil {
+ return fmt.Errorf("routine default-policy migration step failed: %w", err)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit routine default-policy migration: %w", err)
+ }
+ return nil
+}
+
+// routineDefaultPolicyMigrationStatements returns the ordered SQL that
+// rebuilds office_routines with the corrected catch_up_policy DEFAULT. The
+// column list mirrors createRoutineTables' office_routines shape exactly;
+// row values (including catch_up_policy itself, already normalized by the
+// value-level UPDATE that runs immediately before this in migrateRoutineCatchUp)
+// are copied verbatim, so ordering relative to that UPDATE does not matter.
+func routineDefaultPolicyMigrationStatements() []string {
+ return []string{
+ `CREATE TABLE office_routines_new (
+ id TEXT PRIMARY KEY,
+ workspace_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT DEFAULT '',
+ task_template TEXT NOT NULL DEFAULT '{}',
+ assignee_agent_profile_id TEXT DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'active',
+ concurrency_policy TEXT DEFAULT 'skip_if_active',
+ catch_up_policy TEXT NOT NULL DEFAULT 'summarize_missed',
+ catch_up_max INTEGER NOT NULL DEFAULT 25,
+ variables TEXT DEFAULT '{}',
+ last_run_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL
+ )`,
+ `INSERT INTO office_routines_new (
+ id, workspace_id, name, description, task_template,
+ assignee_agent_profile_id, status, concurrency_policy,
+ catch_up_policy, catch_up_max, variables, last_run_at,
+ created_at, updated_at
+ ) SELECT
+ id, workspace_id, name, COALESCE(description,''),
+ COALESCE(task_template,'{}'), COALESCE(assignee_agent_profile_id,''),
+ COALESCE(status,'active'), COALESCE(concurrency_policy,'skip_if_active'),
+ catch_up_policy, catch_up_max, COALESCE(variables,'{}'), last_run_at,
+ created_at, updated_at
+ FROM office_routines`,
+ `DROP TABLE office_routines`,
+ `ALTER TABLE office_routines_new RENAME TO office_routines`,
+ }
+}
diff --git a/apps/backend/internal/office/repository/sqlite/catchup_migrations_postgres.go b/apps/backend/internal/office/repository/sqlite/catchup_migrations_postgres.go
new file mode 100644
index 0000000000..6fe99caf1a
--- /dev/null
+++ b/apps/backend/internal/office/repository/sqlite/catchup_migrations_postgres.go
@@ -0,0 +1,69 @@
+package sqlite
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+
+ "go.uber.org/zap"
+
+ "github.com/kandev/kandev/internal/db/dialect"
+ "github.com/kandev/kandev/internal/office/models"
+)
+
+// migrateRoutineDefaultPolicyRebuildPostgres is the PostgreSQL companion to
+// migrateRoutineDefaultPolicyRebuild: that function detects staleness via
+// sqlite_master and PRAGMA table_info, neither of which exist on Postgres,
+// so without this companion an upgraded Postgres install's
+// office_routines.catch_up_policy column keeps the retired
+// 'enqueue_missed_with_cap' DEFAULT forever, violating
+// AC-OFFICE-ROUTINE-CATCHUP-003.3. Unlike SQLite, Postgres supports
+// ALTER COLUMN ... SET DEFAULT directly, so no table rebuild is needed here
+// at all (mirrors migrateTaskPriorityToTextPostgres's dialect split for the
+// same class of migration in internal/task/repository/sqlite).
+func (r *Repository) migrateRoutineDefaultPolicyRebuildPostgres() error {
+ if !dialect.IsPostgres(r.db.DriverName()) {
+ return nil
+ }
+
+ var dflt sql.NullString
+ err := r.db.Get(&dflt, `
+ SELECT column_default
+ FROM information_schema.columns
+ WHERE table_schema = current_schema()
+ AND table_name = 'office_routines'
+ AND column_name = 'catch_up_policy'
+ `)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("inspect office_routines.catch_up_policy default: %w", err)
+ }
+ if !dflt.Valid || !strings.Contains(dflt.String, string(models.CatchUpPolicyEnqueueMissedWithCap)) {
+ return nil
+ }
+
+ if _, err := r.db.Exec(fmt.Sprintf(
+ `ALTER TABLE office_routines ALTER COLUMN catch_up_policy SET DEFAULT '%s'`,
+ models.CatchUpPolicySummarizeMissed,
+ )); err != nil {
+ return fmt.Errorf("set office_routines.catch_up_policy default: %w", err)
+ }
+ return nil
+}
+
+// applyRoutineDefaultPolicyRebuildPostgres runs the Postgres rebuild and
+// logs its own outcome, matching migrateRoutineCatchUp's existing
+// warn-and-continue handling for the SQLite path: a failed schema-hygiene
+// fix must never block boot.
+func (r *Repository) applyRoutineDefaultPolicyRebuildPostgres() {
+ if err := r.migrateRoutineDefaultPolicyRebuildPostgres(); err != nil {
+ if r.log != nil {
+ r.log.Warn("routine catch-up default-policy postgres rebuild failed", zap.Error(err))
+ } else {
+ fmt.Println("office sqlite migrate routine catch-up default policy (postgres):", err)
+ }
+ }
+}
diff --git a/apps/backend/internal/office/repository/sqlite/catchup_repo_test.go b/apps/backend/internal/office/repository/sqlite/catchup_repo_test.go
new file mode 100644
index 0000000000..9724b2f937
--- /dev/null
+++ b/apps/backend/internal/office/repository/sqlite/catchup_repo_test.go
@@ -0,0 +1,415 @@
+package sqlite_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/kandev/kandev/internal/office/models"
+)
+
+func seedCatchUpRoutine(t *testing.T, repo interface {
+ CreateRoutine(ctx context.Context, r *models.Routine) error
+}, id string, catchUpMax int) *models.Routine {
+ t.Helper()
+ routine := &models.Routine{
+ ID: id,
+ WorkspaceID: "ws-1",
+ Name: "routine-" + id,
+ Status: "active",
+ ConcurrencyPolicy: models.ConcurrencyPolicyAlwaysCreate,
+ CatchUpMax: catchUpMax,
+ Variables: "{}",
+ }
+ if err := repo.CreateRoutine(context.Background(), routine); err != nil {
+ t.Fatalf("CreateRoutine: %v", err)
+ }
+ return routine
+}
+
+// TestCreateRoutine_CatchUpMaxNormalized covers AC-OFFICE-ROUTINE-CATCHUP-001.13
+// on the create path: 0 clamps to the default floor, 5000 clamps to the
+// ceiling, and the mutated struct returned to the caller (not only a
+// subsequent GET) already reports the normalized value.
+func TestCreateRoutine_CatchUpMaxNormalized(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+
+ zero := &models.Routine{ID: "r-zero", WorkspaceID: "ws-1", Name: "zero", Variables: "{}", CatchUpMax: 0}
+ if err := repo.CreateRoutine(ctx, zero); err != nil {
+ t.Fatalf("CreateRoutine: %v", err)
+ }
+ if zero.CatchUpMax != models.CatchUpMaxDefault {
+ t.Errorf("in-place CatchUpMax = %d, want %d", zero.CatchUpMax, models.CatchUpMaxDefault)
+ }
+ got, err := repo.GetRoutine(ctx, "r-zero")
+ if err != nil {
+ t.Fatalf("GetRoutine: %v", err)
+ }
+ if got.CatchUpMax != models.CatchUpMaxDefault {
+ t.Errorf("read-back CatchUpMax = %d, want %d", got.CatchUpMax, models.CatchUpMaxDefault)
+ }
+
+ over := &models.Routine{ID: "r-over", WorkspaceID: "ws-1", Name: "over", Variables: "{}", CatchUpMax: 5000}
+ if err := repo.CreateRoutine(ctx, over); err != nil {
+ t.Fatalf("CreateRoutine: %v", err)
+ }
+ if over.CatchUpMax != models.CatchUpMaxCeiling {
+ t.Errorf("in-place CatchUpMax = %d, want %d", over.CatchUpMax, models.CatchUpMaxCeiling)
+ }
+ got, err = repo.GetRoutine(ctx, "r-over")
+ if err != nil {
+ t.Fatalf("GetRoutine: %v", err)
+ }
+ if got.CatchUpMax != models.CatchUpMaxCeiling {
+ t.Errorf("read-back CatchUpMax = %d, want %d", got.CatchUpMax, models.CatchUpMaxCeiling)
+ }
+}
+
+// TestCreateRoutineTx_CatchUpMaxNormalized is CreateRoutine's transaction
+// sibling — config-sync's own creation path, which bypasses CreateRoutine
+// entirely, must clamp identically.
+func TestCreateRoutineTx_CatchUpMaxNormalized(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ tx, err := repo.Writer().BeginTxx(ctx, nil)
+ if err != nil {
+ t.Fatalf("BeginTxx: %v", err)
+ }
+ routine := &models.Routine{ID: "r-tx-over", WorkspaceID: "ws-1", Name: "over", Variables: "{}", CatchUpMax: 5000}
+ if err := repo.CreateRoutineTx(ctx, tx, routine); err != nil {
+ t.Fatalf("CreateRoutineTx: %v", err)
+ }
+ if err := tx.Commit(); err != nil {
+ t.Fatalf("Commit: %v", err)
+ }
+ if routine.CatchUpMax != models.CatchUpMaxCeiling {
+ t.Errorf("in-place CatchUpMax = %d, want %d", routine.CatchUpMax, models.CatchUpMaxCeiling)
+ }
+ got, err := repo.GetRoutine(ctx, "r-tx-over")
+ if err != nil {
+ t.Fatalf("GetRoutine: %v", err)
+ }
+ if got.CatchUpMax != models.CatchUpMaxCeiling {
+ t.Errorf("read-back CatchUpMax = %d, want %d", got.CatchUpMax, models.CatchUpMaxCeiling)
+ }
+}
+
+// TestUpdateRoutine_CatchUpMaxNormalized covers the update path — the hole
+// that existed on both ends before this change (UpdateRoutine wrote
+// catch_up_max completely unbound).
+func TestUpdateRoutine_CatchUpMaxNormalized(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ routine := seedCatchUpRoutine(t, repo, "r-upd", 25)
+
+ routine.CatchUpMax = 0
+ if err := repo.UpdateRoutine(ctx, routine); err != nil {
+ t.Fatalf("UpdateRoutine: %v", err)
+ }
+ if routine.CatchUpMax != models.CatchUpMaxDefault {
+ t.Errorf("in-place CatchUpMax after 0 = %d, want %d", routine.CatchUpMax, models.CatchUpMaxDefault)
+ }
+
+ routine.CatchUpMax = 5000
+ if err := repo.UpdateRoutine(ctx, routine); err != nil {
+ t.Fatalf("UpdateRoutine: %v", err)
+ }
+ if routine.CatchUpMax != models.CatchUpMaxCeiling {
+ t.Errorf("in-place CatchUpMax after 5000 = %d, want %d", routine.CatchUpMax, models.CatchUpMaxCeiling)
+ }
+ got, err := repo.GetRoutine(ctx, routine.ID)
+ if err != nil {
+ t.Fatalf("GetRoutine: %v", err)
+ }
+ if got.CatchUpMax != models.CatchUpMaxCeiling {
+ t.Errorf("read-back CatchUpMax = %d, want %d", got.CatchUpMax, models.CatchUpMaxCeiling)
+ }
+}
+
+// TestGetDueTriggers_OrderedByNextRunThenID covers AC-OFFICE-ROUTINE-CATCHUP-001.7:
+// triggers due at the same instant are returned in ascending id order, and
+// an earlier next_run_at always sorts first regardless of id.
+func TestGetDueTriggers_OrderedByNextRunThenID(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ routine := seedCatchUpRoutine(t, repo, "r-order", 25)
+
+ now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
+ tied := now.Add(-time.Minute)
+ earlier := now.Add(-2 * time.Minute)
+
+ mustCreateTrigger := func(id string, next time.Time) {
+ t.Helper()
+ if err := repo.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ ID: id, RoutineID: routine.ID, Kind: "cron", CronExpression: "* * * * *",
+ NextRunAt: &next, Enabled: true,
+ }); err != nil {
+ t.Fatalf("CreateRoutineTrigger(%s): %v", id, err)
+ }
+ }
+ // Two triggers tied on next_run_at, seeded out of id order, plus one
+ // due earlier that must sort first despite a "larger" id.
+ mustCreateTrigger("trigger-z", tied)
+ mustCreateTrigger("trigger-a", tied)
+ mustCreateTrigger("trigger-m-earliest", earlier)
+
+ due, err := repo.GetDueTriggers(ctx, now)
+ if err != nil {
+ t.Fatalf("GetDueTriggers: %v", err)
+ }
+ if len(due) != 3 {
+ t.Fatalf("len(due) = %d, want 3", len(due))
+ }
+ gotIDs := []string{due[0].ID, due[1].ID, due[2].ID}
+ wantIDs := []string{"trigger-m-earliest", "trigger-a", "trigger-z"}
+ for i := range wantIDs {
+ if gotIDs[i] != wantIDs[i] {
+ t.Errorf("due[%d].ID = %q, want %q (got order %v)", i, gotIDs[i], wantIDs[i], gotIDs)
+ }
+ }
+}
+
+// TestListStrandedTriggers_LiveClaimIsNotStale covers the discriminator
+// AC-OFFICE-ROUTINE-CATCHUP-001.9 relies on: a claim taken this tick
+// (updated_at inside catchUpReclaimAfter) must not be returned as stranded.
+func TestListStrandedTriggers_LiveClaimIsNotStale(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ routine := seedCatchUpRoutine(t, repo, "r-live", 25)
+
+ now := time.Now().UTC()
+ claimAt := now.Add(-time.Minute)
+ if err := repo.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ ID: "trigger-live", RoutineID: routine.ID, Kind: "cron", CronExpression: "* * * * *",
+ NextRunAt: &claimAt, Enabled: true,
+ }); err != nil {
+ t.Fatalf("CreateRoutineTrigger: %v", err)
+ }
+ claimed, err := repo.ClaimTrigger(ctx, "trigger-live", claimAt)
+ if err != nil || !claimed {
+ t.Fatalf("ClaimTrigger: claimed=%v err=%v", claimed, err)
+ }
+
+ // A claim taken just now (updated_at very fresh) is well inside any
+ // reasonable reclaim window, so a cutoff far in the past finds nothing.
+ stranded, err := repo.ListStrandedTriggers(ctx, now.Add(-10*time.Minute))
+ if err != nil {
+ t.Fatalf("ListStrandedTriggers: %v", err)
+ }
+ for _, tr := range stranded {
+ if tr.ID == "trigger-live" {
+ t.Fatalf("live claim returned as stranded: %+v", tr)
+ }
+ }
+
+ // The same row IS stranded once the cutoff is recent enough to include it.
+ stranded, err = repo.ListStrandedTriggers(ctx, now.Add(time.Minute))
+ if err != nil {
+ t.Fatalf("ListStrandedTriggers: %v", err)
+ }
+ found := false
+ for _, tr := range stranded {
+ if tr.ID == "trigger-live" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("trigger-live not found once cutoff is recent enough")
+ }
+}
+
+// TestReconcileTriggerNextRun_CASSemantics covers the reconciliation
+// write's compare-and-swap: it only affects a row still claimed (next_run_at
+// IS NULL, enabled = 1), so a second reconciler racing the first affects
+// zero rows and a disabled trigger is left alone.
+func TestReconcileTriggerNextRun_CASSemantics(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ routine := seedCatchUpRoutine(t, repo, "r-cas", 25)
+
+ next := time.Now().UTC().Add(-time.Minute)
+ if err := repo.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ ID: "trigger-cas", RoutineID: routine.ID, Kind: "cron", CronExpression: "* * * * *",
+ NextRunAt: &next, Enabled: true,
+ }); err != nil {
+ t.Fatalf("CreateRoutineTrigger: %v", err)
+ }
+ claimed, err := repo.ClaimTrigger(ctx, "trigger-cas", next)
+ if err != nil || !claimed {
+ t.Fatalf("ClaimTrigger: claimed=%v err=%v", claimed, err)
+ }
+
+ armTo := time.Now().UTC().Add(time.Minute)
+ won, err := repo.ReconcileTriggerNextRun(ctx, "trigger-cas", armTo)
+ if err != nil {
+ t.Fatalf("ReconcileTriggerNextRun: %v", err)
+ }
+ if !won {
+ t.Fatal("first reconcile did not win the CAS")
+ }
+
+ // A second reconciler racing the first affects zero rows: next_run_at
+ // is no longer null, so the WHERE predicate matches nothing.
+ againTo := armTo.Add(time.Minute)
+ wonAgain, err := repo.ReconcileTriggerNextRun(ctx, "trigger-cas", againTo)
+ if err != nil {
+ t.Fatalf("second ReconcileTriggerNextRun: %v", err)
+ }
+ if wonAgain {
+ t.Fatal("second reconcile won the CAS, want it to affect zero rows")
+ }
+}
+
+// TestReconcileTriggerNextRun_ArmedValueWins asserts the first CAS write
+// actually landed the intended next_run_at (not just that it reported
+// success), by re-listing due triggers at that instant.
+func TestReconcileTriggerNextRun_ArmedValueWins(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ routine := seedCatchUpRoutine(t, repo, "r-cas2", 25)
+
+ next := time.Now().UTC().Add(-time.Minute)
+ if err := repo.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ ID: "trigger-cas2", RoutineID: routine.ID, Kind: "cron", CronExpression: "* * * * *",
+ NextRunAt: &next, Enabled: true,
+ }); err != nil {
+ t.Fatalf("CreateRoutineTrigger: %v", err)
+ }
+ if claimed, err := repo.ClaimTrigger(ctx, "trigger-cas2", next); err != nil || !claimed {
+ t.Fatalf("ClaimTrigger: claimed=%v err=%v", claimed, err)
+ }
+
+ armTo := time.Now().UTC().Add(time.Hour)
+ if won, err := repo.ReconcileTriggerNextRun(ctx, "trigger-cas2", armTo); err != nil || !won {
+ t.Fatalf("ReconcileTriggerNextRun: won=%v err=%v", won, err)
+ }
+
+ due, err := repo.GetDueTriggers(ctx, armTo.Add(time.Minute))
+ if err != nil {
+ t.Fatalf("GetDueTriggers: %v", err)
+ }
+ found := false
+ for _, tr := range due {
+ if tr.ID == "trigger-cas2" {
+ found = true
+ if tr.NextRunAt == nil || !tr.NextRunAt.Equal(armTo) {
+ t.Errorf("NextRunAt = %v, want %v", tr.NextRunAt, armTo)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("reconciled trigger not found among due triggers at the armed instant")
+ }
+}
+
+// TestCreateRoutineRun_GapSummaryRoundTrip covers the NULL-versus-zero
+// distinction (AC-OFFICE-ROUTINE-CATCHUP-002.3): a run created with no gap
+// summary must read back with a nil CatchUpMissedTicks, not a stored zero,
+// while a run created with one round-trips exactly.
+func TestCreateRoutineRun_GapSummaryRoundTrip(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ routine := seedCatchUpRoutine(t, repo, "r-gap", 25)
+
+ noGap := &models.RoutineRun{
+ ID: "run-no-gap", RoutineID: routine.ID, Source: "cron",
+ Status: models.RoutineRunStatusReceived, TriggerPayload: "{}",
+ }
+ if err := repo.CreateRoutineRun(ctx, noGap); err != nil {
+ t.Fatalf("CreateRoutineRun (no gap): %v", err)
+ }
+
+ missed := 5
+ firstMissed := time.Date(2026, 5, 10, 11, 55, 0, 0, time.UTC)
+ withGap := &models.RoutineRun{
+ ID: "run-with-gap", RoutineID: routine.ID, Source: "cron",
+ Status: models.RoutineRunStatusReceived, TriggerPayload: "{}",
+ CatchUpMissedTicks: &missed, CatchUpFirstMissedAt: &firstMissed, CatchUpTruncated: true,
+ }
+ if err := repo.CreateRoutineRun(ctx, withGap); err != nil {
+ t.Fatalf("CreateRoutineRun (with gap): %v", err)
+ }
+
+ runs, err := repo.ListRoutineRuns(ctx, routine.ID, 10, 0)
+ if err != nil {
+ t.Fatalf("ListRoutineRuns: %v", err)
+ }
+ byID := map[string]*models.RoutineRun{}
+ for _, r := range runs {
+ byID[r.ID] = r
+ }
+
+ gotNoGap, ok := byID["run-no-gap"]
+ if !ok {
+ t.Fatal("run-no-gap not found")
+ }
+ if gotNoGap.CatchUpMissedTicks != nil {
+ t.Errorf("no-gap CatchUpMissedTicks = %v, want nil", *gotNoGap.CatchUpMissedTicks)
+ }
+ if gotNoGap.CatchUpFirstMissedAt != nil {
+ t.Errorf("no-gap CatchUpFirstMissedAt = %v, want nil", *gotNoGap.CatchUpFirstMissedAt)
+ }
+ if gotNoGap.CatchUpTruncated {
+ t.Error("no-gap CatchUpTruncated = true, want false")
+ }
+
+ gotWithGap, ok := byID["run-with-gap"]
+ if !ok {
+ t.Fatal("run-with-gap not found")
+ }
+ if gotWithGap.CatchUpMissedTicks == nil || *gotWithGap.CatchUpMissedTicks != missed {
+ t.Errorf("with-gap CatchUpMissedTicks = %v, want %d", gotWithGap.CatchUpMissedTicks, missed)
+ }
+ if gotWithGap.CatchUpFirstMissedAt == nil || !gotWithGap.CatchUpFirstMissedAt.Equal(firstMissed) {
+ t.Errorf("with-gap CatchUpFirstMissedAt = %v, want %v", gotWithGap.CatchUpFirstMissedAt, firstMissed)
+ }
+ if !gotWithGap.CatchUpTruncated {
+ t.Error("with-gap CatchUpTruncated = false, want true")
+ }
+}
+
+// TestCreateRoutineRun_GapSummaryWriteOnce covers AC-OFFICE-ROUTINE-CATCHUP-002.10:
+// the three gap columns are unchanged after a run transitions through
+// coalesced and failed.
+func TestCreateRoutineRun_GapSummaryWriteOnce(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ routine := seedCatchUpRoutine(t, repo, "r-write-once", 25)
+
+ missed := 3
+ firstMissed := time.Date(2026, 5, 10, 11, 55, 0, 0, time.UTC)
+ run := &models.RoutineRun{
+ ID: "run-once", RoutineID: routine.ID, Source: "cron",
+ Status: models.RoutineRunStatusReceived, TriggerPayload: "{}",
+ CatchUpMissedTicks: &missed, CatchUpFirstMissedAt: &firstMissed, CatchUpTruncated: false,
+ }
+ if err := repo.CreateRoutineRun(ctx, run); err != nil {
+ t.Fatalf("CreateRoutineRun: %v", err)
+ }
+
+ if err := repo.UpdateRunCoalesced(ctx, run.ID, "run-other"); err != nil {
+ t.Fatalf("UpdateRunCoalesced: %v", err)
+ }
+ if err := repo.UpdateRunStatus(ctx, run.ID, models.RoutineRunStatusFailed, ""); err != nil {
+ t.Fatalf("UpdateRunStatus: %v", err)
+ }
+
+ runs, err := repo.ListRoutineRuns(ctx, routine.ID, 10, 0)
+ if err != nil {
+ t.Fatalf("ListRoutineRuns: %v", err)
+ }
+ if len(runs) != 1 {
+ t.Fatalf("len(runs) = %d, want 1", len(runs))
+ }
+ got := runs[0]
+ if got.Status != models.RoutineRunStatusFailed {
+ t.Fatalf("Status = %q, want failed", got.Status)
+ }
+ if got.CatchUpMissedTicks == nil || *got.CatchUpMissedTicks != missed {
+ t.Errorf("CatchUpMissedTicks = %v, want %d (unchanged)", got.CatchUpMissedTicks, missed)
+ }
+ if got.CatchUpFirstMissedAt == nil || !got.CatchUpFirstMissedAt.Equal(firstMissed) {
+ t.Errorf("CatchUpFirstMissedAt = %v, want %v (unchanged)", got.CatchUpFirstMissedAt, firstMissed)
+ }
+}
diff --git a/apps/backend/internal/office/repository/sqlite/routines.go b/apps/backend/internal/office/repository/sqlite/routines.go
index b60d841d2f..0669f51278 100644
--- a/apps/backend/internal/office/repository/sqlite/routines.go
+++ b/apps/backend/internal/office/repository/sqlite/routines.go
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
+ "github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/office/models"
)
@@ -60,7 +61,9 @@ func (r *Repository) GetTriggerByPublicID(ctx context.Context, publicID string)
return &t, err
}
-// GetDueTriggers returns cron triggers that are due to fire.
+// GetDueTriggers returns cron triggers that are due to fire, ordered by
+// next_run_at then id so that TickScheduledTriggers processes the same
+// trigger set in a stable order across ticks (AC-OFFICE-ROUTINE-CATCHUP-001.7).
// Routine status filtering is done in the service layer via ConfigLoader.
func (r *Repository) GetDueTriggers(ctx context.Context, now time.Time) ([]*models.RoutineTrigger, error) {
var triggers []*models.RoutineTrigger
@@ -68,6 +71,7 @@ func (r *Repository) GetDueTriggers(ctx context.Context, now time.Time) ([]*mode
SELECT * FROM office_routine_triggers
WHERE kind = 'cron' AND enabled = 1
AND next_run_at IS NOT NULL AND next_run_at <= ?
+ ORDER BY next_run_at ASC, id ASC
`), now)
if err != nil {
return nil, err
@@ -103,6 +107,48 @@ func (r *Repository) UpdateTriggerNextRun(ctx context.Context, triggerID string,
return err
}
+// ListStrandedTriggers returns enabled cron triggers whose next_run_at is
+// null (a claim was taken via ClaimTrigger) and whose updated_at is older
+// than olderThan. A claim taken this tick or the previous one is never
+// returned; only a claim abandoned before that — a process that stopped
+// between ClaimTrigger and the re-arm write — is stale
+// (AC-OFFICE-ROUTINE-CATCHUP-001.9).
+func (r *Repository) ListStrandedTriggers(ctx context.Context, olderThan time.Time) ([]*models.RoutineTrigger, error) {
+ var triggers []*models.RoutineTrigger
+ err := r.ro.SelectContext(ctx, &triggers, r.ro.Rebind(`
+ SELECT * FROM office_routine_triggers
+ WHERE kind = 'cron' AND enabled = 1
+ AND next_run_at IS NULL AND updated_at < ?
+ ORDER BY id ASC
+ `), olderThan)
+ if err != nil {
+ return nil, err
+ }
+ if triggers == nil {
+ triggers = []*models.RoutineTrigger{}
+ }
+ return triggers, nil
+}
+
+// ReconcileTriggerNextRun arms a stranded trigger's next_run_at. Unlike
+// UpdateTriggerNextRun (the live claimant's own unconditional re-arm), this
+// write is a CAS on `next_run_at IS NULL AND enabled = 1`: a second
+// reconciling processor affects zero rows, and a trigger disabled or armed
+// between the read and this write is left alone. Returns true if this call
+// won the CAS.
+func (r *Repository) ReconcileTriggerNextRun(ctx context.Context, triggerID string, nextRunAt time.Time) (bool, error) {
+ res, err := r.db.ExecContext(ctx, r.db.Rebind(`
+ UPDATE office_routine_triggers
+ SET next_run_at = ?, updated_at = ?
+ WHERE id = ? AND next_run_at IS NULL AND enabled = 1
+ `), nextRunAt, time.Now().UTC(), triggerID)
+ if err != nil {
+ return false, err
+ }
+ rows, err := res.RowsAffected()
+ return rows > 0, err
+}
+
// DeleteRoutineTrigger deletes a trigger by ID.
func (r *Repository) DeleteRoutineTrigger(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(
@@ -118,13 +164,6 @@ func (r *Repository) CreateRoutine(ctx context.Context, routine *models.Routine)
now := time.Now().UTC()
routine.CreatedAt = now
routine.UpdatedAt = now
-
- if routine.CatchUpPolicy == "" {
- routine.CatchUpPolicy = "enqueue_missed_with_cap"
- }
- if routine.CatchUpMax <= 0 {
- routine.CatchUpMax = 25
- }
return r.insertRoutine(ctx, r.db, routine)
}
@@ -138,16 +177,19 @@ func (r *Repository) CreateRoutineTx(ctx context.Context, tx *sqlx.Tx, routine *
now := time.Now().UTC()
routine.CreatedAt = now
routine.UpdatedAt = now
- if routine.CatchUpPolicy == "" {
- routine.CatchUpPolicy = "enqueue_missed_with_cap"
- }
- if routine.CatchUpMax <= 0 {
- routine.CatchUpMax = 25
- }
return r.insertRoutine(ctx, tx, routine)
}
+// insertRoutine is the single write-time normalization funnel for
+// catch_up_policy and catch_up_max: it reassigns both fields on the
+// passed-in struct, before the statement binds them, so a handler that
+// serializes the same *Routine back to the caller reports the normalized
+// value rather than the submitted one (AC-OFFICE-ROUTINE-CATCHUP-001.13,
+// AC-OFFICE-ROUTINE-CATCHUP-003.1). See
+// docs/specs/office/system-design/routine-catch-up-01.md.
func (r *Repository) insertRoutine(ctx context.Context, ext sqlx.ExtContext, routine *models.Routine) error {
+ routine.CatchUpPolicy = models.NormaliseCatchUpPolicy(string(routine.CatchUpPolicy))
+ routine.CatchUpMax = models.NormaliseCatchUpMax(routine.CatchUpMax)
_, err := ext.ExecContext(ctx, r.db.Rebind(`
INSERT INTO office_routines (
id, workspace_id, name, description, task_template,
@@ -196,15 +238,17 @@ func (r *Repository) ListRoutines(ctx context.Context, workspaceID string) ([]*m
return routines, nil
}
-// UpdateRoutine updates an existing routine. It deliberately does not
-// carry last_run_at (AC-OFFICE-LOOP-LIVENESS-001.8): that column only
-// ever moves forward, written by TouchRoutineLastRun from a dispatch, and
-// a caller here may be holding a routine loaded before a fire (or one
-// that never populated the field) — writing it unconditionally would let
-// that stale or nil snapshot silently move the value backwards or clear
-// it. routine.LastRunAt stays on the struct for reads.
+// UpdateRoutine updates an existing routine. It applies the same write-time
+// catch_up_policy/catch_up_max normalization funnel as insertRoutine, on the
+// passed-in struct, so this second, independent writer of both columns
+// cannot diverge from the create path (AC-OFFICE-ROUTINE-CATCHUP-001.13,
+// AC-OFFICE-ROUTINE-CATCHUP-003.1). It deliberately does not carry
+// last_run_at: that column only moves forward through TouchRoutineLastRun,
+// so a stale read-modify-write cannot move it backwards or clear it.
func (r *Repository) UpdateRoutine(ctx context.Context, routine *models.Routine) error {
routine.UpdatedAt = time.Now().UTC()
+ routine.CatchUpPolicy = models.NormaliseCatchUpPolicy(string(routine.CatchUpPolicy))
+ routine.CatchUpMax = models.NormaliseCatchUpMax(routine.CatchUpMax)
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE office_routines SET
name = ?, description = ?, task_template = ?,
@@ -289,7 +333,11 @@ func (r *Repository) deleteRoutine(ctx context.Context, ext sqlx.ExtContext, id
return err
}
-// CreateRoutineRun creates a new routine run record.
+// CreateRoutineRun creates a new routine run record. The three catch-up
+// gap-summary columns are written once, here, from whatever the caller set
+// on run (nil when the claim measured no gap); nothing updates them
+// afterward, so a run that later transitions to coalesced or failed keeps
+// the gap that was measured for its tick (AC-OFFICE-ROUTINE-CATCHUP-002.10).
func (r *Repository) CreateRoutineRun(ctx context.Context, run *models.RoutineRun) error {
if run.ID == "" {
run.ID = uuid.New().String()
@@ -300,11 +348,14 @@ func (r *Repository) CreateRoutineRun(ctx context.Context, run *models.RoutineRu
INSERT INTO office_routine_runs (
id, routine_id, trigger_id, source, status, trigger_payload,
linked_task_id, coalesced_into_run_id, dispatch_fingerprint,
+ catch_up_missed_ticks, catch_up_first_missed_at, catch_up_truncated,
started_at, completed_at, created_at, causation_id
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), run.ID, run.RoutineID, run.TriggerID, run.Source, run.Status,
run.TriggerPayload, run.LinkedTaskID, run.CoalescedIntoRunID,
- run.DispatchFingerprint, run.StartedAt, run.CompletedAt, run.CreatedAt, run.CausationID)
+ run.DispatchFingerprint, run.CatchUpMissedTicks, run.CatchUpFirstMissedAt,
+ dialect.BoolToInt(run.CatchUpTruncated), run.StartedAt, run.CompletedAt, run.CreatedAt,
+ run.CausationID)
return err
}
diff --git a/apps/backend/internal/office/repository/sqlite/routines_tx_test.go b/apps/backend/internal/office/repository/sqlite/routines_tx_test.go
index a662a3fe54..ed5c1a4d17 100644
--- a/apps/backend/internal/office/repository/sqlite/routines_tx_test.go
+++ b/apps/backend/internal/office/repository/sqlite/routines_tx_test.go
@@ -50,9 +50,11 @@ func TestCreateRoutineTx_CommitPersists(t *testing.T) {
got.TaskTemplate != want.TaskTemplate || got.ConcurrencyPolicy != want.ConcurrencyPolicy {
t.Errorf("got %+v, want %+v", got, want)
}
- // CreateRoutineTx must apply the same defaulting as CreateRoutine.
- if got.CatchUpPolicy != "enqueue_missed_with_cap" || got.CatchUpMax != 25 {
- t.Errorf("catch-up defaults = (%q, %d), want (enqueue_missed_with_cap, 25)", got.CatchUpPolicy, got.CatchUpMax)
+ // CreateRoutineTx must apply the same defaulting as CreateRoutine
+ // (AC-OFFICE-ROUTINE-CATCHUP-003.1: no creation path persists the
+ // deprecated alias, even when CatchUpPolicy is left unset).
+ if got.CatchUpPolicy != "summarize_missed" || got.CatchUpMax != 25 {
+ t.Errorf("catch-up defaults = (%q, %d), want (summarize_missed, 25)", got.CatchUpPolicy, got.CatchUpMax)
}
}
diff --git a/apps/backend/internal/office/repository/sqlite/wakeup_gap_strip_test.go b/apps/backend/internal/office/repository/sqlite/wakeup_gap_strip_test.go
new file mode 100644
index 0000000000..5328f3900a
--- /dev/null
+++ b/apps/backend/internal/office/repository/sqlite/wakeup_gap_strip_test.go
@@ -0,0 +1,163 @@
+package sqlite_test
+
+import (
+ "context"
+ "testing"
+
+ officemodels "github.com/kandev/kandev/internal/office/models"
+ "github.com/kandev/kandev/internal/office/repository/sqlite"
+ "github.com/kandev/kandev/internal/office/shared"
+)
+
+// mergeEntryPoint abstracts over the two coalesce-merge call sites so the
+// table test below exercises both with identical fixtures and assertions
+// (docs/specs/office/system-design/routine-catch-up-02.md "Testing": "Every
+// case below runs against BOTH merge entry points ... That is not
+// belt-and-braces: until the refactor lands they are two separate SQL
+// statements, and a suite that exercises only the helper passes green with
+// the promoted-run path fully broken").
+type mergeEntryPoint struct {
+ name string
+ merge func(t *testing.T, repo *sqlite.Repository, ctx context.Context, requestID, runID string)
+}
+
+var mergeEntryPoints = []mergeEntryPoint{
+ {
+ name: "MarkWakeupRequestCoalesced",
+ merge: func(t *testing.T, repo *sqlite.Repository, ctx context.Context, requestID, runID string) {
+ t.Helper()
+ if err := repo.MarkWakeupRequestCoalesced(ctx, requestID, runID); err != nil {
+ t.Fatalf("MarkWakeupRequestCoalesced: %v", err)
+ }
+ },
+ },
+ {
+ name: "PromoteRunAndCoalesceWakeupIfQueued",
+ merge: func(t *testing.T, repo *sqlite.Repository, ctx context.Context, requestID, runID string) {
+ t.Helper()
+ ok, err := repo.PromoteRunAndCoalesceWakeupIfQueued(ctx, requestID, runID, shared.RunReasonRoutineDispatchEvent)
+ if err != nil {
+ t.Fatalf("PromoteRunAndCoalesceWakeupIfQueued: %v", err)
+ }
+ if !ok {
+ t.Fatal("PromoteRunAndCoalesceWakeupIfQueued: expected queued run to accept the promote")
+ }
+ },
+ },
+}
+
+// seedGapStripRun creates a queued run with the given snapshot, ready for
+// either merge entry point (PromoteRunAndCoalesceWakeupIfQueued requires
+// status "queued").
+func seedGapStripRun(t *testing.T, repo *sqlite.Repository, ctx context.Context, id, snapshot string) {
+ t.Helper()
+ run := &officemodels.Run{
+ ID: id, AgentProfileID: "agent-1", Reason: shared.RunReasonRoutineDispatchCron,
+ Payload: "{}", Status: "queued", CoalescedCount: 1, ContextSnapshot: snapshot,
+ }
+ if err := repo.CreateRun(ctx, run); err != nil {
+ t.Fatalf("create run: %v", err)
+ }
+}
+
+// TestMergeEntryPoints_GapCarryingWakeIntoGaplessRun covers the direction
+// AC-002.10 forbids: a gap-carrying routine wake coalescing into an
+// in-flight gapless run must leave that run gapless.
+func TestMergeEntryPoints_GapCarryingWakeIntoGaplessRun(t *testing.T) {
+ for _, ep := range mergeEntryPoints {
+ t.Run(ep.name, func(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ seedGapStripRun(t, repo, ctx, "run-gapless-"+ep.name, `{"routine_id":"r-1"}`)
+
+ reqID := "req-gap-" + ep.name
+ if err := repo.CreateWakeupRequest(ctx, &sqlite.WakeupRequest{
+ ID: reqID, AgentProfileID: "agent-1", Source: "routine",
+ Payload: `{"routine_id":"r-1","missed_ticks":5,"missed_since":"2026-05-10T11:55:00Z","missed_truncated":true}`,
+ }); err != nil {
+ t.Fatalf("create wakeup: %v", err)
+ }
+
+ ep.merge(t, repo, ctx, reqID, "run-gapless-"+ep.name)
+
+ gotRun, err := repo.GetRunByID(ctx, "run-gapless-"+ep.name)
+ if err != nil {
+ t.Fatalf("get run: %v", err)
+ }
+ for _, key := range []string{"missed_ticks", "missed_since", "missed_truncated"} {
+ if contains(gotRun.ContextSnapshot, key) {
+ t.Errorf("context_snapshot = %q, must not carry %q from a coalesced wake", gotRun.ContextSnapshot, key)
+ }
+ }
+ if !contains(gotRun.ContextSnapshot, `"routine_id":"r-1"`) {
+ t.Errorf("context_snapshot = %q, want routine_id preserved", gotRun.ContextSnapshot)
+ }
+ })
+ }
+}
+
+// TestMergeEntryPoints_GaplessWakeIntoGapCarryingRun covers the other
+// direction: a gapless wake coalescing into a run that already carries a
+// gap must leave the original gap intact, not half-overwritten.
+func TestMergeEntryPoints_GaplessWakeIntoGapCarryingRun(t *testing.T) {
+ for _, ep := range mergeEntryPoints {
+ t.Run(ep.name, func(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ seedGapStripRun(t, repo, ctx, "run-gap-"+ep.name,
+ `{"routine_id":"r-1","missed_ticks":5,"missed_since":"2026-05-10T11:55:00Z","missed_truncated":true}`)
+
+ reqID := "req-nogap-" + ep.name
+ if err := repo.CreateWakeupRequest(ctx, &sqlite.WakeupRequest{
+ ID: reqID, AgentProfileID: "agent-1", Source: "routine",
+ Payload: `{"routine_id":"r-1"}`,
+ }); err != nil {
+ t.Fatalf("create wakeup: %v", err)
+ }
+
+ ep.merge(t, repo, ctx, reqID, "run-gap-"+ep.name)
+
+ gotRun, err := repo.GetRunByID(ctx, "run-gap-"+ep.name)
+ if err != nil {
+ t.Fatalf("get run: %v", err)
+ }
+ if !contains(gotRun.ContextSnapshot, `"missed_ticks":5`) {
+ t.Errorf("context_snapshot = %q, want the original gap statement intact", gotRun.ContextSnapshot)
+ }
+ if !contains(gotRun.ContextSnapshot, `"missed_truncated":true`) {
+ t.Errorf("context_snapshot = %q, want the original truncated flag intact", gotRun.ContextSnapshot)
+ }
+ })
+ }
+}
+
+// TestMergeEntryPoints_NonRoutinePayloadRoundTripsUnchanged proves the
+// json_remove strip is safe for a source that never writes the three
+// catch-up keys: the merge is byte-for-byte unaffected.
+func TestMergeEntryPoints_NonRoutinePayloadRoundTripsUnchanged(t *testing.T) {
+ for _, ep := range mergeEntryPoints {
+ t.Run(ep.name, func(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ seedGapStripRun(t, repo, ctx, "run-nonroutine-"+ep.name, `{"existing":"old"}`)
+
+ reqID := "req-comment-" + ep.name
+ if err := repo.CreateWakeupRequest(ctx, &sqlite.WakeupRequest{
+ ID: reqID, AgentProfileID: "agent-1", Source: "comment",
+ Payload: `{"task_id":"t-1","comment_id":"c-1"}`,
+ }); err != nil {
+ t.Fatalf("create wakeup: %v", err)
+ }
+
+ ep.merge(t, repo, ctx, reqID, "run-nonroutine-"+ep.name)
+
+ gotRun, err := repo.GetRunByID(ctx, "run-nonroutine-"+ep.name)
+ if err != nil {
+ t.Fatalf("get run: %v", err)
+ }
+ if !contains(gotRun.ContextSnapshot, `"task_id":"t-1"`) || !contains(gotRun.ContextSnapshot, `"comment_id":"c-1"`) {
+ t.Errorf("context_snapshot = %q, want the non-routine payload merged unchanged", gotRun.ContextSnapshot)
+ }
+ })
+ }
+}
diff --git a/apps/backend/internal/office/repository/sqlite/wakeup_json_patch_pin_test.go b/apps/backend/internal/office/repository/sqlite/wakeup_json_patch_pin_test.go
new file mode 100644
index 0000000000..d9176368ad
--- /dev/null
+++ b/apps/backend/internal/office/repository/sqlite/wakeup_json_patch_pin_test.go
@@ -0,0 +1,66 @@
+package sqlite_test
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+// TestWakeupRequestsJSONPatchAppearsOnce is a source-level pinning
+// assertion for the coalesce-merge unification
+// (docs/specs/office/system-design/routine-catch-up-01.md, "Delivering the
+// gap to the agent"): wakeup_requests.go must contain exactly one SQL
+// occurrence of json_patch, meaning exactly one place a coalescing wakeup
+// request's payload is merged into a run's context_snapshot. Two SQL sites
+// would mean only one of them carries the AC-OFFICE-ROUTINE-CATCHUP-002.10
+// gap-key strip (json_remove of missed_ticks/missed_since/missed_truncated),
+// silently reintroducing the bug this design fixes.
+//
+// The naive `grep -c json_patch` on this file returns THREE lines: the two
+// SQL string literals this test counts, plus the helper's own doc comment,
+// which names the function in prose. Parsing with go/parser and counting
+// only *ast.BasicLit string literals excludes the comment by construction —
+// an unqualified "exactly once" assertion would FAIL against the correct
+// implementation.
+func TestWakeupRequestsJSONPatchAppearsOnce(t *testing.T) {
+ _, sourceFile, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("runtime.Caller failed")
+ }
+ targetPath := filepath.Join(filepath.Dir(sourceFile), "wakeup_requests.go")
+
+ fileSet := token.NewFileSet()
+ file, err := parser.ParseFile(fileSet, targetPath, nil, 0)
+ if err != nil {
+ t.Fatalf("parse %s: %v", targetPath, err)
+ }
+
+ count := 0
+ ast.Inspect(file, func(n ast.Node) bool {
+ lit, ok := n.(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ return true
+ }
+ if containsJSONPatch(lit.Value) {
+ count++
+ }
+ return true
+ })
+ if count != 1 {
+ t.Fatalf("json_patch appears in %d SQL string literal(s) in wakeup_requests.go, want exactly 1 "+
+ "(the single mergeWakeupPayloadIntoRunSnapshotWith call site)", count)
+ }
+}
+
+func containsJSONPatch(s string) bool {
+ const needle = "json_patch"
+ for i := 0; i+len(needle) <= len(s); i++ {
+ if s[i:i+len(needle)] == needle {
+ return true
+ }
+ }
+ return false
+}
diff --git a/apps/backend/internal/office/repository/sqlite/wakeup_requests.go b/apps/backend/internal/office/repository/sqlite/wakeup_requests.go
index 10151299b5..f1bbe7dadb 100644
--- a/apps/backend/internal/office/repository/sqlite/wakeup_requests.go
+++ b/apps/backend/internal/office/repository/sqlite/wakeup_requests.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/jackc/pgx/v5/pgconn"
+ "github.com/jmoiron/sqlx"
)
// Wakeup request status constants — kept in sync with the spec's
@@ -234,14 +235,7 @@ func (r *Repository) PromoteRunAndCoalesceWakeupIfQueued(
`), runID); err != nil {
return false, err
}
- if _, err := tx.ExecContext(ctx, tx.Rebind(`
- UPDATE runs
- SET context_snapshot = json_patch(
- COALESCE(NULLIF(context_snapshot, ''), '{}'),
- (SELECT payload FROM agent_wakeup_requests WHERE id = ?)
- )
- WHERE id = ?
- `), requestID, runID); err != nil {
+ if err := mergeWakeupPayloadIntoRunSnapshotWith(ctx, tx, requestID, runID); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
@@ -261,18 +255,46 @@ func (r *Repository) bumpRunCoalescedCount(ctx context.Context, runID string) er
}
// mergeWakeupPayloadIntoRunSnapshot merges the wakeup request's payload
-// into the target run's context_snapshot via SQLite's json_patch (top-
-// level merge: keys from the request payload overwrite same-named keys
-// already on the snapshot). When the snapshot is empty / NULL it
-// initialises to "{}" first so the patch lands on a valid object.
+// into the target run's context_snapshot. Delegates to
+// mergeWakeupPayloadIntoRunSnapshotWith on r.db — the non-transactional
+// merge site; PromoteRunAndCoalesceWakeupIfQueued calls the same helper on
+// its own tx instead of duplicating the SQL, so there is exactly one
+// json_patch call site for both merge entry points.
func (r *Repository) mergeWakeupPayloadIntoRunSnapshot(
ctx context.Context, requestID, runID string,
) error {
- _, err := r.db.ExecContext(ctx, r.db.Rebind(`
+ return mergeWakeupPayloadIntoRunSnapshotWith(ctx, r.db, requestID, runID)
+}
+
+// mergeWakeupPayloadIntoRunSnapshotWith merges the wakeup request's
+// payload into the target run's context_snapshot via SQLite's json_patch
+// (top-level merge: keys from the request payload overwrite same-named
+// keys already on the snapshot). When the snapshot is empty / NULL it
+// initialises to "{}" first so the patch lands on a valid object.
+//
+// The three routine catch-up gap-summary keys are stripped from the
+// incoming payload before the patch via json_remove: a coalesced or
+// promoted wakeup request never adds, changes, or removes the gap
+// statement an existing run's agent already carries
+// (AC-OFFICE-ROUTINE-CATCHUP-002.10) — each run's gap belongs only to the
+// claim that created it. json_remove of an absent key is a no-op in
+// SQLite, so this is byte-for-byte safe for every non-routine wakeup
+// source, none of which writes these keys.
+//
+// exec is r.db for the non-transactional caller or a *sqlx.Tx for the
+// transactional one — sqlx.ExtContext (which both satisfy) already
+// includes Rebind via its embedded binder interface.
+func mergeWakeupPayloadIntoRunSnapshotWith(
+ ctx context.Context, exec sqlx.ExtContext, requestID, runID string,
+) error {
+ _, err := exec.ExecContext(ctx, exec.Rebind(`
UPDATE runs
SET context_snapshot = json_patch(
COALESCE(NULLIF(context_snapshot, ''), '{}'),
- (SELECT payload FROM agent_wakeup_requests WHERE id = ?)
+ json_remove(
+ (SELECT payload FROM agent_wakeup_requests WHERE id = ?),
+ '$.missed_ticks', '$.missed_since', '$.missed_truncated'
+ )
)
WHERE id = ?
`), requestID, runID)
diff --git a/apps/backend/internal/office/routines/catch_up_test.go b/apps/backend/internal/office/routines/catch_up_test.go
index f797449150..7f2c95958e 100644
--- a/apps/backend/internal/office/routines/catch_up_test.go
+++ b/apps/backend/internal/office/routines/catch_up_test.go
@@ -7,33 +7,40 @@ import (
"github.com/kandev/kandev/internal/office/models"
)
-// TestComputeRoutineMissed_HappyPath: NextRunAt == now, no backlog —
-// expect runCount=1 (one fire due now), advance to next cron tick.
-func TestComputeRoutineMissed_HappyPath(t *testing.T) {
+// TestComputeCatchUp_HappyPath: NextRunAt == now, no backlog — expect
+// ElapsedTicks=1 (one fire due now), NextRunAt advances to the next cron
+// tick, and no gap summary (FirstMissedAt zero, not truncated).
+func TestComputeCatchUp_HappyPath(t *testing.T) {
t0 := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
trigger := &RoutineTrigger{
CronExpression: "* * * * *",
Timezone: "",
NextRunAt: &t0,
}
- routine := &Routine{CatchUpPolicy: models.CatchUpPolicyEnqueueMissedWithCap, CatchUpMax: 25}
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicySummarizeMissed, CatchUpMax: 25}
- count, advanceTo, err := computeRoutineMissed(trigger, routine, t0)
- if err != nil {
- t.Fatalf("computeRoutineMissed: %v", err)
+ result := computeCatchUp(trigger, routine, t0)
+ if result.Unknown {
+ t.Fatalf("Unknown = true, want false")
}
- if count != 1 {
- t.Errorf("count = %d, want 1", count)
+ if result.ElapsedTicks != 1 {
+ t.Errorf("ElapsedTicks = %d, want 1", result.ElapsedTicks)
}
- if !advanceTo.Equal(t0.Add(time.Minute)) {
- t.Errorf("advanceTo = %v, want %v", advanceTo, t0.Add(time.Minute))
+ if !result.FirstMissedAt.IsZero() {
+ t.Errorf("FirstMissedAt = %v, want zero (ElapsedTicks <= 1)", result.FirstMissedAt)
+ }
+ if result.Truncated {
+ t.Error("Truncated = true, want false")
+ }
+ if !result.NextRunAt.Equal(t0.Add(time.Minute)) {
+ t.Errorf("NextRunAt = %v, want %v", result.NextRunAt, t0.Add(time.Minute))
}
}
-// TestComputeRoutineMissed_BackendDownTenMinutes: NextRunAt is 10 minutes
-// before now with an "every minute" cron — expect 11 ticks counted (10
-// missed + 1 due now), advance to the next-minute tick.
-func TestComputeRoutineMissed_BackendDownTenMinutes(t *testing.T) {
+// TestComputeCatchUp_BackendDownTenMinutes: NextRunAt is 10 minutes before
+// now with an "every minute" cron — expect 11 elapsed ticks (10 missed + 1
+// due now), FirstMissedAt exactly the armed next_run_at, not truncated.
+func TestComputeCatchUp_BackendDownTenMinutes(t *testing.T) {
now := time.Date(2026, 5, 10, 12, 10, 0, 0, time.UTC)
tenAgo := now.Add(-10 * time.Minute)
trigger := &RoutineTrigger{
@@ -41,24 +48,30 @@ func TestComputeRoutineMissed_BackendDownTenMinutes(t *testing.T) {
Timezone: "",
NextRunAt: &tenAgo,
}
- routine := &Routine{CatchUpPolicy: models.CatchUpPolicyEnqueueMissedWithCap, CatchUpMax: 25}
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicySummarizeMissed, CatchUpMax: 25}
- count, advanceTo, err := computeRoutineMissed(trigger, routine, now)
- if err != nil {
- t.Fatalf("computeRoutineMissed: %v", err)
+ result := computeCatchUp(trigger, routine, now)
+ if result.Unknown {
+ t.Fatalf("Unknown = true, want false")
}
- if count != 11 {
- t.Errorf("count = %d, want 11", count)
+ if result.ElapsedTicks != 11 {
+ t.Errorf("ElapsedTicks = %d, want 11", result.ElapsedTicks)
}
- if !advanceTo.Equal(now.Add(time.Minute)) {
- t.Errorf("advanceTo = %v, want %v", advanceTo, now.Add(time.Minute))
+ if !result.FirstMissedAt.Equal(tenAgo) {
+ t.Errorf("FirstMissedAt = %v, want %v (exact armed next_run_at)", result.FirstMissedAt, tenAgo)
+ }
+ if result.Truncated {
+ t.Error("Truncated = true, want false")
+ }
+ if !result.NextRunAt.Equal(now.Add(time.Minute)) {
+ t.Errorf("NextRunAt = %v, want %v", result.NextRunAt, now.Add(time.Minute))
}
}
-// TestComputeRoutineMissed_HitsCap: 100 minutes missed with cap 5 —
-// expect runCount=5 (capped), advanceTo = next future tick from now so
-// the trigger leaves the catch-up window cleanly.
-func TestComputeRoutineMissed_HitsCap(t *testing.T) {
+// TestComputeCatchUp_HitsCap: 100 minutes missed with cap 5 — expect
+// ElapsedTicks=5 (capped), Truncated=true, FirstMissedAt still exact and
+// un-truncated, NextRunAt strictly after now.
+func TestComputeCatchUp_HitsCap(t *testing.T) {
now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
hundredAgo := now.Add(-100 * time.Minute)
trigger := &RoutineTrigger{
@@ -66,44 +79,69 @@ func TestComputeRoutineMissed_HitsCap(t *testing.T) {
Timezone: "",
NextRunAt: &hundredAgo,
}
- routine := &Routine{CatchUpPolicy: models.CatchUpPolicyEnqueueMissedWithCap, CatchUpMax: 5}
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicySummarizeMissed, CatchUpMax: 5}
- count, advanceTo, err := computeRoutineMissed(trigger, routine, now)
- if err != nil {
- t.Fatalf("computeRoutineMissed: %v", err)
+ result := computeCatchUp(trigger, routine, now)
+ if result.Unknown {
+ t.Fatalf("Unknown = true, want false")
}
- if count != 5 {
- t.Errorf("count = %d, want 5 (capped)", count)
+ if result.ElapsedTicks != 5 {
+ t.Errorf("ElapsedTicks = %d, want 5 (capped)", result.ElapsedTicks)
}
- if !advanceTo.After(now) {
- t.Errorf("advanceTo = %v, want a time after %v", advanceTo, now)
+ if !result.Truncated {
+ t.Error("Truncated = false, want true")
+ }
+ if !result.FirstMissedAt.Equal(hundredAgo) {
+ t.Errorf("FirstMissedAt = %v, want %v (exact, even truncated)", result.FirstMissedAt, hundredAgo)
+ }
+ if !result.NextRunAt.After(now) {
+ t.Errorf("NextRunAt = %v, want a time after %v", result.NextRunAt, now)
}
}
-// TestComputeRoutineMissed_DefaultCap: CatchUpMax of 0 falls back to
-// the default (25). 100 minutes missed produces runCount=25.
-func TestComputeRoutineMissed_DefaultCap(t *testing.T) {
+// TestComputeCatchUp_DefaultCap: CatchUpMax of 0 clamps to the default
+// (25) via models.NormaliseCatchUpMax. 100 minutes missed produces
+// ElapsedTicks=25 (AC-001.4).
+func TestComputeCatchUp_DefaultCap(t *testing.T) {
now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
hundredAgo := now.Add(-100 * time.Minute)
trigger := &RoutineTrigger{
CronExpression: "* * * * *",
NextRunAt: &hundredAgo,
}
- // CatchUpMax=0 → defaultCatchUpMax (25).
- routine := &Routine{CatchUpPolicy: models.CatchUpPolicyEnqueueMissedWithCap, CatchUpMax: 0}
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicySummarizeMissed, CatchUpMax: 0}
- count, _, err := computeRoutineMissed(trigger, routine, now)
- if err != nil {
- t.Fatalf("computeRoutineMissed: %v", err)
- }
- if count != defaultCatchUpMax {
- t.Errorf("count = %d, want %d (defaultCatchUpMax)", count, defaultCatchUpMax)
+ result := computeCatchUp(trigger, routine, now)
+ if result.ElapsedTicks != models.CatchUpMaxDefault {
+ t.Errorf("ElapsedTicks = %d, want %d (CatchUpMaxDefault)", result.ElapsedTicks, models.CatchUpMaxDefault)
}
}
-// TestComputeRoutineMissed_HourlyCron: NextRunAt is 5 hours before now
-// with an hourly cron — expect 6 ticks counted (5 missed + 1 due).
-func TestComputeRoutineMissed_HourlyCron(t *testing.T) {
+// TestComputeCatchUp_CeilingClamp: CatchUpMax of 5000 clamps to the
+// ceiling (1000) via models.NormaliseCatchUpMax (AC-001.4). A gap of 2000
+// minutes with an every-minute cron produces ElapsedTicks capped at 1000,
+// not 2000.
+func TestComputeCatchUp_CeilingClamp(t *testing.T) {
+ now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
+ longAgo := now.Add(-2000 * time.Minute)
+ trigger := &RoutineTrigger{
+ CronExpression: "* * * * *",
+ NextRunAt: &longAgo,
+ }
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicySummarizeMissed, CatchUpMax: 5000}
+
+ result := computeCatchUp(trigger, routine, now)
+ if result.ElapsedTicks != models.CatchUpMaxCeiling {
+ t.Errorf("ElapsedTicks = %d, want %d (CatchUpMaxCeiling)", result.ElapsedTicks, models.CatchUpMaxCeiling)
+ }
+ if !result.Truncated {
+ t.Error("Truncated = false, want true")
+ }
+}
+
+// TestComputeCatchUp_HourlyCron: NextRunAt is 5 hours before now with an
+// hourly cron — expect 6 elapsed ticks (5 missed + 1 due).
+func TestComputeCatchUp_HourlyCron(t *testing.T) {
now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
fiveAgo := now.Add(-5 * time.Hour)
trigger := &RoutineTrigger{
@@ -112,14 +150,166 @@ func TestComputeRoutineMissed_HourlyCron(t *testing.T) {
}
routine := &Routine{CatchUpMax: 25}
- count, advanceTo, err := computeRoutineMissed(trigger, routine, now)
- if err != nil {
- t.Fatalf("computeRoutineMissed: %v", err)
+ result := computeCatchUp(trigger, routine, now)
+ if result.ElapsedTicks != 6 {
+ t.Errorf("ElapsedTicks = %d, want 6", result.ElapsedTicks)
}
- if count != 6 {
- t.Errorf("count = %d, want 6", count)
- }
- if !advanceTo.Equal(now.Add(time.Hour)) {
- t.Errorf("advanceTo = %v, want %v", advanceTo, now.Add(time.Hour))
+ if !result.NextRunAt.Equal(now.Add(time.Hour)) {
+ t.Errorf("NextRunAt = %v, want %v", result.NextRunAt, now.Add(time.Hour))
+ }
+}
+
+// TestComputeCatchUp_ElapsedTicksOne_NoFirstMissedAt: exactly one elapsed
+// tick means missedTicks == 0, so FirstMissedAt stays zero even though the
+// walk succeeded — this is the boundary buildGapSummary relies on to
+// record no gap summary.
+func TestComputeCatchUp_ElapsedTicksOne_NoFirstMissedAt(t *testing.T) {
+ now := time.Date(2026, 5, 10, 12, 0, 30, 0, time.UTC)
+ oneAgo := now.Add(-30 * time.Second)
+ trigger := &RoutineTrigger{
+ CronExpression: "* * * * *",
+ NextRunAt: &oneAgo,
+ }
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicySummarizeMissed, CatchUpMax: 25}
+
+ result := computeCatchUp(trigger, routine, now)
+ if result.ElapsedTicks != 1 {
+ t.Fatalf("ElapsedTicks = %d, want 1", result.ElapsedTicks)
+ }
+ if !result.FirstMissedAt.IsZero() {
+ t.Errorf("FirstMissedAt = %v, want zero", result.FirstMissedAt)
+ }
+}
+
+// TestComputeCatchUp_MalformedExpression: an unparseable cron expression
+// drives Unknown=true and NextRunAt exactly 24 hours after the processing
+// instant (AC-001.11) — the failure is deterministic in the stored
+// (expression, timezone) pair, so the re-arm is the trigger's permanent
+// dispatch cadence until it is deleted and recreated.
+func TestComputeCatchUp_MalformedExpression(t *testing.T) {
+ now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
+ past := now.Add(-time.Hour)
+ trigger := &RoutineTrigger{
+ CronExpression: "not a cron expression",
+ NextRunAt: &past,
+ }
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicySummarizeMissed, CatchUpMax: 25}
+
+ result := computeCatchUp(trigger, routine, now)
+ if !result.Unknown {
+ t.Fatal("Unknown = false, want true")
+ }
+ want := now.Add(catchUpFallbackInterval)
+ if !result.NextRunAt.Equal(want) {
+ t.Errorf("NextRunAt = %v, want %v (now + 24h)", result.NextRunAt, want)
+ }
+ // AC-001.11 requires the eventual warning to name "the underlying
+ // error", which means it has to survive on catchUpResult rather than
+ // being discarded at the point of failure.
+ if result.Err == nil {
+ t.Error("Err = nil, want the underlying NextCronTime parse error")
+ }
+
+ // A second tick a day later dispatches once more, not repeatedly: the
+ // re-armed NextRunAt is itself now due, and re-computing from it
+ // produces the same 24h fallback rather than a hot loop.
+ again := computeCatchUp(trigger, routine, result.NextRunAt)
+ if !again.Unknown {
+ t.Fatal("second computeCatchUp: Unknown = false, want true")
+ }
+ if !again.NextRunAt.Equal(result.NextRunAt.Add(catchUpFallbackInterval)) {
+ t.Errorf("second NextRunAt = %v, want %v", again.NextRunAt, result.NextRunAt.Add(catchUpFallbackInterval))
+ }
+}
+
+// TestBuildGapSummary_SkipMissedRecordsNothing: skip_missed never records
+// a gap summary, whatever the elapsed-tick count (AC-002.7).
+func TestBuildGapSummary_SkipMissedRecordsNothing(t *testing.T) {
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicySkipMissed}
+ result := catchUpResult{ElapsedTicks: 10, FirstMissedAt: time.Now(), Truncated: true}
+ if gap := buildGapSummary(routine, result); gap != nil {
+ t.Errorf("gap = %+v, want nil", gap)
+ }
+}
+
+// TestBuildGapSummary_UnknownRecordsNothing: a failed walk never records a
+// gap, even though ElapsedTicks is technically non-meaningful (AC-001.6).
+func TestBuildGapSummary_UnknownRecordsNothing(t *testing.T) {
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicySummarizeMissed}
+ result := catchUpResult{Unknown: true}
+ if gap := buildGapSummary(routine, result); gap != nil {
+ t.Errorf("gap = %+v, want nil", gap)
+ }
+}
+
+// TestBuildGapSummary_ZeroMissedRecordsNothing: exactly one elapsed tick
+// (missedTicks == 0) records no gap summary, whatever truncated would say
+// (AC-002.2).
+func TestBuildGapSummary_ZeroMissedRecordsNothing(t *testing.T) {
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicySummarizeMissed}
+ result := catchUpResult{ElapsedTicks: 1}
+ if gap := buildGapSummary(routine, result); gap != nil {
+ t.Errorf("gap = %+v, want nil", gap)
+ }
+}
+
+// TestBuildGapSummary_CatchUpMaxOne: catch_up_max == 1 makes missedTicks
+// structurally zero regardless of the gap's real size — an opt-out of gap
+// reporting, not an edge case (AC-002.11).
+func TestBuildGapSummary_CatchUpMaxOne(t *testing.T) {
+ now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
+ longAgo := now.Add(-100 * time.Hour)
+ trigger := &RoutineTrigger{CronExpression: "* * * * *", NextRunAt: &longAgo}
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicySummarizeMissed, CatchUpMax: 1}
+
+ // Truncation and a zero missed-tick count can co-occur only at exactly
+ // catch_up_max == 1 (above 1 a truncated count is always >= cap - 1);
+ // what matters here is that no gap summary is ever produced, whatever
+ // Truncated says.
+ result := computeCatchUp(trigger, routine, now)
+ if gap := buildGapSummary(routine, result); gap != nil {
+ t.Errorf("gap = %+v, want nil (catch_up_max == 1 opts out)", gap)
+ }
+}
+
+// TestBuildGapSummary_SummarizeMissedRecordsGap: the happy path — a
+// multi-tick gap under summarize_missed records the count, the exact
+// first-missed timestamp, and truncated.
+func TestBuildGapSummary_SummarizeMissedRecordsGap(t *testing.T) {
+ first := time.Date(2026, 5, 10, 11, 55, 0, 0, time.UTC)
+ result := catchUpResult{ElapsedTicks: 6, FirstMissedAt: first, Truncated: true}
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicySummarizeMissed}
+
+ gap := buildGapSummary(routine, result)
+ if gap == nil {
+ t.Fatal("gap = nil, want non-nil")
+ }
+ if gap.MissedTicks != 5 {
+ t.Errorf("MissedTicks = %d, want 5", gap.MissedTicks)
+ }
+ if !gap.FirstMissed.Equal(first) {
+ t.Errorf("FirstMissed = %v, want %v", gap.FirstMissed, first)
+ }
+ if !gap.Truncated {
+ t.Error("Truncated = false, want true")
+ }
+}
+
+// TestBuildGapSummary_DeprecatedAliasStillSummarizes proves the alias
+// normalizes to summarize_missed's behaviour rather than being treated as
+// an unrecognized (and thus also summarizing, per models.NormaliseCatchUpPolicy)
+// value by accident — the policy comparison in buildGapSummary only ever
+// special-cases skip_missed, so any non-skip value, including the raw
+// alias constant, produces a gap summary.
+func TestBuildGapSummary_DeprecatedAliasStillSummarizes(t *testing.T) {
+ result := catchUpResult{ElapsedTicks: 3, FirstMissedAt: time.Now()}
+ routine := &Routine{CatchUpPolicy: models.CatchUpPolicyEnqueueMissedWithCap}
+
+ gap := buildGapSummary(routine, result)
+ if gap == nil {
+ t.Fatal("gap = nil, want non-nil")
+ }
+ if gap.MissedTicks != 2 {
+ t.Errorf("MissedTicks = %d, want 2", gap.MissedTicks)
}
}
diff --git a/apps/backend/internal/office/routines/handler.go b/apps/backend/internal/office/routines/handler.go
index c0373a07c4..1fe5bb4169 100644
--- a/apps/backend/internal/office/routines/handler.go
+++ b/apps/backend/internal/office/routines/handler.go
@@ -104,7 +104,7 @@ func (h *Handler) createRoutine(c *gin.Context) {
}
catchUpPolicy := models.RoutineCatchUpPolicy(req.CatchUpPolicy)
if catchUpPolicy == "" {
- catchUpPolicy = models.CatchUpPolicyEnqueueMissedWithCap
+ catchUpPolicy = models.CatchUpPolicySummarizeMissed
}
if !concurrencyPolicy.Valid() {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid concurrency_policy: " + req.ConcurrencyPolicy})
diff --git a/apps/backend/internal/office/routines/handler_catchup_test.go b/apps/backend/internal/office/routines/handler_catchup_test.go
new file mode 100644
index 0000000000..518ce60304
--- /dev/null
+++ b/apps/backend/internal/office/routines/handler_catchup_test.go
@@ -0,0 +1,97 @@
+package routines_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/kandev/kandev/internal/office/models"
+ "github.com/kandev/kandev/internal/office/routines"
+)
+
+// newTestRoutineRouter mounts the routine HTTP routes over a fresh
+// RoutineService backed by in-memory SQLite.
+func newTestRoutineRouter(t *testing.T) *gin.Engine {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ svc := newTestRoutineService(t)
+ router := gin.New()
+ routines.RegisterRoutes(router.Group("/api/v1"), routines.NewHandler(svc))
+ return router
+}
+
+func doRoutineRequest(t *testing.T, router *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(method, path, strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+}
+
+// TestCreateRoutineHandler_CatchUpMaxNormalizedInResponse covers
+// AC-OFFICE-ROUTINE-CATCHUP-001.13: a routine read back through the API
+// must report the same normalized catch_up_max the tick honours, not the
+// raw value the client submitted.
+func TestCreateRoutineHandler_CatchUpMaxNormalizedInResponse(t *testing.T) {
+ router := newTestRoutineRouter(t)
+
+ rec := doRoutineRequest(t, router, http.MethodPost, "/api/v1/workspaces/ws-1/routines",
+ `{"name":"Handler routine","catch_up_max":5000,"variables":"{}"}`)
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("status = %d, want 201, body = %s", rec.Code, rec.Body.String())
+ }
+ var resp routines.RoutineResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if resp.Routine.CatchUpMax != models.CatchUpMaxCeiling {
+ t.Errorf("response catch_up_max = %d, want %d (ceiling)", resp.Routine.CatchUpMax, models.CatchUpMaxCeiling)
+ }
+}
+
+// TestUpdateRoutineHandler_CatchUpMaxNormalizedInResponse is the same
+// contract on the PATCH path: submitting a below-floor value must come
+// back normalized in the same response, not only on a subsequent GET.
+func TestUpdateRoutineHandler_CatchUpMaxNormalizedInResponse(t *testing.T) {
+ router := newTestRoutineRouter(t)
+
+ createRec := doRoutineRequest(t, router, http.MethodPost, "/api/v1/workspaces/ws-1/routines",
+ `{"name":"Handler routine","catch_up_max":25,"variables":"{}"}`)
+ if createRec.Code != http.StatusCreated {
+ t.Fatalf("create status = %d, want 201, body = %s", createRec.Code, createRec.Body.String())
+ }
+ var created routines.RoutineResponse
+ if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
+ t.Fatalf("decode create response: %v", err)
+ }
+
+ updateRec := doRoutineRequest(t, router, http.MethodPatch, "/api/v1/routines/"+created.Routine.ID,
+ `{"catch_up_max":0}`)
+ if updateRec.Code != http.StatusOK {
+ t.Fatalf("update status = %d, want 200, body = %s", updateRec.Code, updateRec.Body.String())
+ }
+ var updated routines.RoutineResponse
+ if err := json.Unmarshal(updateRec.Body.Bytes(), &updated); err != nil {
+ t.Fatalf("decode update response: %v", err)
+ }
+ if updated.Routine.CatchUpMax != models.CatchUpMaxDefault {
+ t.Errorf("response catch_up_max = %d, want %d (default floor)", updated.Routine.CatchUpMax, models.CatchUpMaxDefault)
+ }
+
+ getRec := doRoutineRequest(t, router, http.MethodGet, "/api/v1/routines/"+created.Routine.ID, "")
+ if getRec.Code != http.StatusOK {
+ t.Fatalf("get status = %d, want 200, body = %s", getRec.Code, getRec.Body.String())
+ }
+ var fetched routines.RoutineResponse
+ if err := json.Unmarshal(getRec.Body.Bytes(), &fetched); err != nil {
+ t.Fatalf("decode get response: %v", err)
+ }
+ if fetched.Routine.CatchUpMax != models.CatchUpMaxDefault {
+ t.Errorf("read-back catch_up_max = %d, want %d", fetched.Routine.CatchUpMax, models.CatchUpMaxDefault)
+ }
+}
diff --git a/apps/backend/internal/office/routines/service.go b/apps/backend/internal/office/routines/service.go
index fd1eb29d0e..827bf05579 100644
--- a/apps/backend/internal/office/routines/service.go
+++ b/apps/backend/internal/office/routines/service.go
@@ -28,6 +28,11 @@ import (
// concrete adapter translates the sqlite layer's
// ErrWakeupIdempotencyConflict into this sentinel so routines never
// imports office/repository/sqlite directly (see routineWakeupAdapter).
+// It is not a materialisation failure (AC-OFFICE-ROUTINE-CATCHUP-001.10
+// second sentence): a duplicate wakeup request within the same
+// wall-clock minute is a successful dedup, so
+// materialiseLightweightRoutineRun absorbs it as a terminal "done" run
+// rather than failing.
var ErrWakeupAlreadyRequested = errors.New("wakeup request already requested")
// ErrInvalidTrigger indicates a trigger create request that failed
@@ -49,6 +54,8 @@ type Repository interface {
GetDueTriggers(ctx context.Context, now time.Time) ([]*RoutineTrigger, error)
ClaimTrigger(ctx context.Context, triggerID string, oldNextRunAt time.Time) (bool, error)
UpdateTriggerNextRun(ctx context.Context, triggerID string, nextRunAt *time.Time) error
+ ListStrandedTriggers(ctx context.Context, olderThan time.Time) ([]*RoutineTrigger, error)
+ ReconcileTriggerNextRun(ctx context.Context, triggerID string, nextRunAt time.Time) (bool, error)
DeleteRoutineTrigger(ctx context.Context, id string) error
CreateRoutineRun(ctx context.Context, run *RoutineRun) error
@@ -185,7 +192,7 @@ const CoordinatorRoutineCron = "*/5 * * * *"
// CreateDefaultCoordinatorRoutine installs the pre-baked
// "Coordinator heartbeat" routine for a coordinator-role agent. The
// shape is described in office-heartbeat-as-routine: lightweight
-// (empty task_template), coalesce_if_active, enqueue_missed_with_cap
+// (empty task_template), coalesce_if_active, summarize_missed
// with a max of 25, status=active, with a single cron trigger firing
// every five minutes (UTC).
//
@@ -210,7 +217,7 @@ func (s *RoutineService) CreateDefaultCoordinatorRoutine(
AssigneeAgentProfileID: agentID,
Status: "active",
ConcurrencyPolicy: models.ConcurrencyPolicyCoalesceIfActive,
- CatchUpPolicy: models.CatchUpPolicyEnqueueMissedWithCap,
+ CatchUpPolicy: models.CatchUpPolicySummarizeMissed,
CatchUpMax: 25,
Variables: "{}",
}
@@ -401,7 +408,27 @@ func (s *RoutineService) ListAllRoutineRuns(ctx context.Context, wsID string, li
// -- Dispatch --
-// TickScheduledTriggers queries due cron triggers, claims each, and dispatches.
+// catchUpFallbackInterval is the re-arm interval computeCatchUp uses when
+// the elapsed-tick walk fails for a reason other than
+// shared.ErrUnsatisfiableCron (AC-OFFICE-ROUTINE-CATCHUP-001.11). The
+// failure is deterministic in the trigger's stored (expression, timezone)
+// pair, not in the time argument: re-arming to the original due time
+// instead would make the trigger due again on the very next 30-second
+// scheduler tick, retrying (and failing) forever with no backoff. 24 hours
+// caps the trigger at one dispatch a day until it is deleted and
+// recreated, matching shared.findNextMatch's own give-up interval.
+const catchUpFallbackInterval = 24 * time.Hour
+
+// catchUpReclaimAfter is how stale a claimed-but-never-armed trigger's
+// updated_at must be before the reconciliation pass in TickScheduledTriggers
+// treats it as abandoned rather than a live claim still mid-tick — enough
+// headroom that a claim taken this tick or the previous one is never
+// reconciled (AC-OFFICE-ROUTINE-CATCHUP-001.9).
+const catchUpReclaimAfter = 60 * time.Second
+
+// TickScheduledTriggers queries due cron triggers, claims and dispatches
+// each in ascending (next_run_at, id) order (AC-001.7), then reconciles
+// any trigger left claimed-but-unarmed by a process that stopped mid-tick.
func (s *RoutineService) TickScheduledTriggers(ctx context.Context, now time.Time) error {
service.IncLoopCronTick(now.Format(time.RFC3339))
triggers, err := s.repo.GetDueTriggers(ctx, now)
@@ -414,9 +441,37 @@ func (s *RoutineService) TickScheduledTriggers(ctx context.Context, now time.Tim
zap.String("trigger_id", trigger.ID), zap.Error(err))
}
}
+ s.reconcileStrandedTriggers(ctx, now)
return nil
}
+// reconcileStrandedTriggers arms enabled cron triggers whose claim was
+// abandoned before the re-arm write landed. It never dispatches a run by
+// this path (AC-OFFICE-ROUTINE-CATCHUP-001.9): the row cannot distinguish
+// "crashed after claiming" from "crashed after claiming and dispatching",
+// and a spurious wake is the more expensive error. A parse failure leaves
+// next_run_at null and only warns — no claim was taken by this path, so no
+// run is owed and AC-001.11's fallback does not apply.
+func (s *RoutineService) reconcileStrandedTriggers(ctx context.Context, now time.Time) {
+ stale, err := s.repo.ListStrandedTriggers(ctx, now.Add(-catchUpReclaimAfter))
+ if err != nil {
+ s.logger.Warn("list stranded triggers", zap.Error(err))
+ return
+ }
+ for _, trigger := range stale {
+ next, err := shared.NextCronTime(trigger.CronExpression, trigger.Timezone, now)
+ if err != nil {
+ s.logger.Warn("reconcile stranded trigger: compute next run",
+ zap.String("trigger_id", trigger.ID), zap.Error(err))
+ continue
+ }
+ if _, err := s.repo.ReconcileTriggerNextRun(ctx, trigger.ID, next); err != nil {
+ s.logger.Warn("reconcile stranded trigger: arm",
+ zap.String("trigger_id", trigger.ID), zap.Error(err))
+ }
+ }
+}
+
func (s *RoutineService) processCronTrigger(ctx context.Context, trigger *RoutineTrigger, now time.Time) error {
if trigger.NextRunAt == nil {
return nil
@@ -433,51 +488,41 @@ func (s *RoutineService) processCronTrigger(ctx context.Context, trigger *Routin
return fmt.Errorf("get routine: %w", err)
}
service.IncLoopTriggerClaimed(routine.WorkspaceID)
- // Catch-up cap: count how many cron ticks elapsed between the missed
- // trigger.NextRunAt (inclusive) and now, capped at routine.CatchUpMax.
- // Mirror of the agent_heartbeat catch-up math, adapted for cron
- // expressions (no fixed interval — we walk NextCronTime).
- runCount, advanceTo, err := computeRoutineMissed(trigger, routine, now)
- if err != nil {
- if errors.Is(err, shared.ErrUnsatisfiableCron) {
+ result := computeCatchUp(trigger, routine, now)
+ if result.Unknown {
+ if errors.Is(result.Err, shared.ErrUnsatisfiableCron) {
// The expression can never fire again. ClaimTrigger already
// cleared next_run_at; leave it cleared rather than re-arming,
// which would dispatch nothing but retry (and fail) forever.
s.logger.Error("cron expression unsatisfiable; trigger permanently disarmed",
- zap.String("trigger_id", trigger.ID), zap.Error(err))
- return err
+ zap.String("trigger_id", trigger.ID), zap.Error(result.Err))
+ return result.Err
}
- // Any other failure (e.g. the timezone database is temporarily
- // unavailable) is presumed recoverable: re-arm to the original due
- // time so the next tick retries instead of leaving the trigger
- // disarmed forever once the underlying issue clears.
- if rearmErr := s.repo.UpdateTriggerNextRun(ctx, trigger.ID, trigger.NextRunAt); rearmErr != nil {
- s.logger.Warn("re-arm trigger after recoverable catch-up failure failed",
- zap.String("trigger_id", trigger.ID), zap.Error(rearmErr))
- }
- s.logger.Warn("compute routine catch-up failed; will retry next tick",
- zap.String("trigger_id", trigger.ID), zap.Error(err))
- return err
+ // AC-OFFICE-ROUTINE-CATCHUP-001.11: any other computation failure
+ // (e.g. an unparseable expression/timezone, or the timezone
+ // database being temporarily unavailable) arms next_run_at to
+ // now+24h below rather than leaving the trigger disarmed forever
+ // or retrying every 30-second tick with no backoff.
+ s.logger.Warn("compute routine catch-up failed",
+ zap.String("trigger_id", trigger.ID),
+ zap.String("cron_expression", trigger.CronExpression),
+ zap.Error(result.Err))
}
// The claimed tick: UpdateTriggerNextRun below advances the trigger
// row's next_run_at to the next slot, so trigger.NextRunAt is captured
// here, before that write, and carried through to the key builder
// rather than re-read afterward.
claimedTick := trigger.NextRunAt
- if err := s.repo.UpdateTriggerNextRun(ctx, trigger.ID, &advanceTo); err != nil {
+ if err := s.repo.UpdateTriggerNextRun(ctx, trigger.ID, &result.NextRunAt); err != nil {
+ // AC-001.2: an arming-write failure means this claim dispatches
+ // nothing at all; next_run_at is left null for the reconciliation
+ // pass (AC-001.9) to arm on a later tick. That tick is lost.
s.logger.Warn("update trigger next_run_at failed",
zap.String("trigger_id", trigger.ID), zap.Error(err))
+ return fmt.Errorf("arm trigger: %w", err)
}
- missedForPayload := 0
- if routine.CatchUpPolicy == models.CatchUpPolicySkipMissed {
- // skip_missed: collapse all misses, fire once with no attribution.
- missedForPayload = 0
- } else if runCount > 0 {
- // enqueue_missed_with_cap (default and unknowns): fire once, surface
- // runCount-1 as missed_ticks ("you missed N since the last fire").
- missedForPayload = runCount - 1
- }
- _, err = s.DispatchRoutineRunWithMissed(ctx, routine, trigger, shared.RoutineSourceCron, nil, missedForPayload, claimedTick)
+ gap := buildGapSummary(routine, result)
+ run, err := s.dispatchRoutineRun(ctx, routine, trigger, shared.RoutineSourceCron, nil, "", gap, claimedTick)
if errors.Is(err, shared.ErrWorkspacePaused) {
// A confirmed operator pause is not a cron-tick failure — the
// blocked fire is already recorded as a skipped run, and
@@ -485,55 +530,114 @@ func (s *RoutineService) processCronTrigger(ctx context.Context, trigger *Routin
// would otherwise page on-call for expected behaviour.
return nil
}
+ if err != nil && run != nil {
+ // The status flip has one home: whichever branch of dispatch
+ // failed, the run created for this claim is marked failed exactly
+ // once, here (AC-001.10).
+ if updateErr := s.repo.UpdateRunStatus(ctx, run.ID, models.RoutineRunStatusFailed, ""); updateErr != nil {
+ s.logger.Warn("mark routine run failed",
+ zap.String("run_id", run.ID), zap.Error(updateErr))
+ }
+ }
return err
}
-const defaultCatchUpMax = 25
+// catchUpResult is what computeCatchUp returns instead of a bare
+// (int, time.Time, error): a single value object so no call site can
+// discard a computed re-arm time on the error path.
+type catchUpResult struct {
+ // ElapsedTicks is >= 1 on success (Unknown false); includes the tick
+ // due now. Not meaningful when Unknown is true.
+ ElapsedTicks int
+ // FirstMissedAt is the armed next_run_at at claim time, read directly
+ // rather than derived from the walk so it stays exact under
+ // truncation. Zero when ElapsedTicks <= 1.
+ FirstMissedAt time.Time
+ // Truncated is true only when the walk stopped at the cap with the
+ // cursor still at or before the processing instant.
+ Truncated bool
+ // NextRunAt is strictly after the processing instant on every path,
+ // including Unknown — it is never `now`. On Unknown it is the
+ // AC-001.11 fallback (now + catchUpFallbackInterval); the caller
+ // overrides it with a permanent disarm only for
+ // shared.ErrUnsatisfiableCron.
+ NextRunAt time.Time
+ // Unknown is true when the walk failed; ElapsedTicks, FirstMissedAt
+ // and Truncated are not meaningful in that case.
+ Unknown bool
+ // Err is the underlying cron computation error when Unknown is true.
+ // Nil otherwise.
+ Err error
+}
-// computeRoutineMissed counts cron ticks between trigger.NextRunAt
-// (inclusive — that's the tick "due now") and `now`, capped at
-// routine.CatchUpMax (default 25). Returns the count plus the next
-// future tick the trigger should re-arm to.
-//
-// For an "every minute" cron with a 10-minute backend outage, this
-// returns 11 (10 missed + 1 due now) and the next-minute tick. With a
-// cap of 5, it returns 5 and aligns the cursor to the next future tick
-// from `now` so the trigger leaves the catch-up window cleanly.
-func computeRoutineMissed(trigger *RoutineTrigger, routine *Routine, now time.Time) (int, time.Time, error) {
- cap := routine.CatchUpMax
- if cap <= 0 {
- cap = defaultCatchUpMax
- }
- cursor := *trigger.NextRunAt
- runCount := 0
- for runCount < cap && !cursor.After(now) {
- runCount++
+// computeCatchUp walks cron ticks between trigger.NextRunAt (inclusive —
+// that's the tick "due now") and now, capped at
+// models.NormaliseCatchUpMax(routine.CatchUpMax). It replaces
+// computeRoutineMissed: catch_up_max bounds only how many elapsed ticks are
+// counted, never how many runs are dispatched
+// (AC-OFFICE-ROUTINE-CATCHUP-001.3).
+func computeCatchUp(trigger *RoutineTrigger, routine *Routine, now time.Time) catchUpResult {
+ cap := models.NormaliseCatchUpMax(routine.CatchUpMax)
+ firstMissedAt := *trigger.NextRunAt
+ cursor := firstMissedAt
+ elapsed := 0
+ for elapsed < cap && !cursor.After(now) {
+ elapsed++
next, err := shared.NextCronTime(trigger.CronExpression, trigger.Timezone, cursor)
if err != nil {
- return runCount, cursor, fmt.Errorf("next cron tick: %w", err)
+ return catchUpResult{NextRunAt: now.Add(catchUpFallbackInterval), Unknown: true, Err: err}
}
cursor = next
}
- if runCount == cap && !cursor.After(now) {
- // Hit the cap with more pending ticks. Advance cursor to the next
- // future tick from now to leave the catch-up window cleanly.
+ truncated := false
+ if elapsed == cap && !cursor.After(now) {
+ // Hit the cap with more pending ticks. Advance the cursor to the
+ // next future tick from now so the trigger leaves the catch-up
+ // window cleanly, and record the count as a lower bound.
next, err := shared.NextCronTime(trigger.CronExpression, trigger.Timezone, now)
if err != nil {
- return runCount, cursor, fmt.Errorf("next cron tick from now: %w", err)
+ return catchUpResult{NextRunAt: now.Add(catchUpFallbackInterval), Unknown: true, Err: err}
}
cursor = next
+ truncated = true
}
- if runCount == 0 {
- // Defensive: ClaimTrigger should only succeed when NextRunAt <= now,
- // so the loop must have iterated at least once. If clock skew leaves
- // us here, fire once and advance by one tick.
- next, err := shared.NextCronTime(trigger.CronExpression, trigger.Timezone, *trigger.NextRunAt)
- if err != nil {
- return 1, *trigger.NextRunAt, fmt.Errorf("defensive next cron tick: %w", err)
- }
- return 1, next, nil
+ result := catchUpResult{ElapsedTicks: elapsed, NextRunAt: cursor, Truncated: truncated}
+ if elapsed > 1 {
+ result.FirstMissedAt = firstMissedAt
}
- return runCount, cursor, nil
+ return result
+}
+
+// gapSummary is the durable measurement of one claim's gap, threaded from
+// processCronTrigger through the dispatch chain into the created run's
+// three catch_up_* columns and, for a lightweight routine under the
+// summarizing policy, the wakeup payload. A nil *gapSummary means no gap
+// summary applies for this claim.
+type gapSummary struct {
+ MissedTicks int
+ FirstMissed time.Time
+ Truncated bool
+}
+
+// buildGapSummary decides whether a claim's computed catch-up result
+// produces a gap summary. A gap summary exists only when the walk
+// succeeded, the policy is summarize_missed, and at least one tick was
+// missed — collapsing what would otherwise be several near-identical states
+// into one rule (AC-002.1, AC-002.2, AC-002.7, AC-002.11, AC-002.12: manual
+// and webhook fires never reach this function, since only processCronTrigger
+// calls it).
+func buildGapSummary(routine *Routine, result catchUpResult) *gapSummary {
+ if result.Unknown {
+ return nil // AC-001.6: never report a gap that was not measured.
+ }
+ if routine.CatchUpPolicy == models.CatchUpPolicySkipMissed {
+ return nil // AC-002.7
+ }
+ missed := result.ElapsedTicks - 1
+ if missed < 1 {
+ return nil // AC-002.2 (zero missed); AC-002.11 (catch_up_max == 1).
+ }
+ return &gapSummary{MissedTicks: missed, FirstMissed: result.FirstMissedAt, Truncated: result.Truncated}
}
// DispatchRoutineRun resolves variables, applies concurrency policy, creates run.
@@ -549,6 +653,10 @@ func computeRoutineMissed(trigger *RoutineTrigger, routine *Routine, now time.Ti
// - heavy (task_template != "") — create a real task in the routine
// system workflow (auto_start_agent on the start step kicks off the
// agent). LinkedTaskID is the new task's id.
+//
+// Manual and webhook fires call this directly and never carry a gap
+// summary (AC-OFFICE-ROUTINE-CATCHUP-002.12) — gap measurement applies only
+// to a cron trigger's armed next_run_at, computed by processCronTrigger.
func (s *RoutineService) DispatchRoutineRun(
ctx context.Context,
routine *Routine,
@@ -556,7 +664,7 @@ func (s *RoutineService) DispatchRoutineRun(
source string,
provided map[string]string,
) (*RoutineRun, error) {
- return s.dispatchRoutineRun(ctx, routine, trigger, source, provided, "", 0, nil)
+ return s.dispatchRoutineRun(ctx, routine, trigger, source, provided, "", nil, nil)
}
// DispatchRoutineRunWithIdempotencyKey dispatches a fire with an explicit
@@ -570,34 +678,14 @@ func (s *RoutineService) DispatchRoutineRunWithIdempotencyKey(
provided map[string]string,
idempotencyKey string,
) (*RoutineRun, error) {
- return s.dispatchRoutineRun(ctx, routine, trigger, source, provided, idempotencyKey, 0, nil)
+ return s.dispatchRoutineRun(ctx, routine, trigger, source, provided, idempotencyKey, nil, nil)
}
-// DispatchRoutineRunWithMissed is the cron-tick entry point that
-// surfaces the catch-up cap's missed-tick count to the wakeup payload.
-// missedTicks is set to N-1 by the cron tick when N consecutive cron
-// ticks fell within the catch-up window — the agent gets one fire and
-// learns "you missed N-1 ticks" via wakeup.RoutinePayload.MissedTicks.
-// Manual fires (UI / API) call DispatchRoutineRun directly with no
-// missed-tick attribution.
-//
// claimedTick is the scheduled tick processCronTrigger claimed off the
// trigger row before advancing it — the dedup key's occurrence identity
// for a cron fire. It travels as a parameter rather than being re-read
// from the trigger row, which by dispatch time already names the next
-// slot.
-func (s *RoutineService) DispatchRoutineRunWithMissed(
- ctx context.Context,
- routine *Routine,
- trigger *RoutineTrigger,
- source string,
- provided map[string]string,
- missedTicks int,
- claimedTick *time.Time,
-) (*RoutineRun, error) {
- return s.dispatchRoutineRun(ctx, routine, trigger, source, provided, "", missedTicks, claimedTick)
-}
-
+// slot. nil for manual/webhook fires, which have no claimed slot.
func (s *RoutineService) dispatchRoutineRun(
ctx context.Context,
routine *Routine,
@@ -605,7 +693,7 @@ func (s *RoutineService) dispatchRoutineRun(
source string,
provided map[string]string,
idempotencyKey string,
- missedTicks int,
+ gap *gapSummary,
claimedTick *time.Time,
) (*RoutineRun, error) {
now := time.Now().UTC()
@@ -639,8 +727,18 @@ func (s *RoutineService) dispatchRoutineRun(
// and copied onto every wakeup request and run this fire produces.
CausationID: uuid.New().String(),
}
+ if gap != nil {
+ missed := gap.MissedTicks
+ firstMissed := gap.FirstMissed
+ run.CatchUpMissedTicks = &missed
+ run.CatchUpFirstMissedAt = &firstMissed
+ run.CatchUpTruncated = gap.Truncated
+ }
+ // The gap summary is written once, here, before applyConcurrencyPolicy
+ // runs — a run later marked skipped, coalesced or failed still carries
+ // the gap measured for its tick (AC-002.10).
if err := s.repo.CreateRoutineRun(ctx, run); err != nil {
- return nil, fmt.Errorf("create run: %w", err)
+ return nil, fmt.Errorf("create run: %w", err) // AC-001.12: no run row exists.
}
// AC-003.1/AC-003.9 (Review round 3, R3-4): office_loop_routine_run_total
@@ -678,7 +776,7 @@ func (s *RoutineService) dispatchRoutineRun(
return run, nil
}
- if err := s.materialiseRoutineRun(ctx, routine, run, tmpl, title, description, vars, source, idempotencyKey, missedTicks, claimedTick); err != nil {
+ if err := s.materialiseRoutineRun(ctx, routine, run, tmpl, title, description, vars, source, idempotencyKey, gap, claimedTick); err != nil {
return run, err
}
// A nil error only means materialiseRoutineRun's terminal write
@@ -708,7 +806,7 @@ func (s *RoutineService) dispatchRoutineRun(
// pause reason in the response body" without a second PauseState read —
// which would race a resume between the block decision and the response.
// Every existing errors.Is(err, shared.ErrWorkspacePaused) call site
-// (processCronTrigger below, writeDispatchError, scheduler/wakeup) keeps
+// (processCronTrigger above, writeDispatchError, scheduler/wakeup) keeps
// matching unchanged, since Unwrap chains to the shared sentinel.
type pausedDispatchError struct {
workspaceID string
@@ -763,8 +861,10 @@ func (s *RoutineService) checkPauseGate(ctx context.Context, workspaceID, routin
// (see SyncRunStatus). The lightweight path has no task to wait on, so
// it resolves to a terminal status (done/failed) immediately; see
// materialiseLightweightRoutineRun.
-// missedTicks is forwarded to the lightweight wakeup payload; the heavy
-// path doesn't attribute it (the agent reads context from the task).
+// gap is forwarded to the lightweight wakeup payload; the heavy path
+// doesn't render it — mutating a user's template text with scheduler
+// metadata would be a worse contract than leaving the gap on the routine
+// run where the API exposes it (AC-002.6).
func (s *RoutineService) materialiseRoutineRun(
ctx context.Context,
routine *Routine,
@@ -774,13 +874,13 @@ func (s *RoutineService) materialiseRoutineRun(
vars map[string]string,
source string,
idempotencyKey string,
- missedTicks int,
+ gap *gapSummary,
claimedTick *time.Time,
) error {
if tmpl.Title != "" && s.workflowEnsurer != nil && s.taskCreator != nil {
return s.materialiseHeavyRoutineRun(ctx, routine, run, title, description)
}
- return s.materialiseLightweightRoutineRun(ctx, routine, run, vars, source, idempotencyKey, missedTicks, claimedTick)
+ return s.materialiseLightweightRoutineRun(ctx, routine, run, vars, source, idempotencyKey, gap, claimedTick)
}
// materialiseHeavyRoutineRun creates a real task in the routine system
@@ -828,9 +928,11 @@ func (s *RoutineService) materialiseHeavyRoutineRun(
// wakeup queue because no background poller retries direct dispatch.
// LinkedTaskID stays empty for lightweight runs.
//
-// missedTicks > 0 surfaces in the wakeup payload when the cron tick
-// collapsed N missed fires into one (catch-up cap policy
-// enqueue_missed_with_cap). Zero on the happy path.
+// gap, when non-nil, surfaces in the wakeup payload as the gap this claim
+// measured (catch-up policy summarize_missed). CreateWakeupRequest and
+// Dispatch errors are returned rather than absorbed — the only exception is
+// the idempotency-key-conflict sentinel, which is a successful dedup, not a
+// failed dispatch, and stays a no-op (AC-OFFICE-ROUTINE-CATCHUP-001.10).
func (s *RoutineService) materialiseLightweightRoutineRun(
ctx context.Context,
routine *Routine,
@@ -838,14 +940,14 @@ func (s *RoutineService) materialiseLightweightRoutineRun(
vars map[string]string,
source string,
idempotencyKey string,
- missedTicks int,
+ gap *gapSummary,
claimedTick *time.Time,
) error {
if s.wakeup == nil || routine.AssigneeAgentProfileID == "" {
return s.finalizeLightweightRun(ctx, run, models.RoutineRunStatusDone)
}
idemKey := buildRoutineIdempotencyKey(source, routine.ID, run.TriggerID, idempotencyKey, claimedTick, run.ID)
- payloadStr, _ := marshalRoutinePayload(routine.ID, vars, missedTicks)
+ payloadStr, _ := marshalRoutinePayload(routine.ID, vars, gap)
req := &WakeupRequest{
ID: uuid.New().String(),
AgentProfileID: routine.AssigneeAgentProfileID,
@@ -934,17 +1036,24 @@ func buildRoutineIdempotencyKey(
// marshalRoutinePayload renders the wakeup-request payload for a
// routine fire as JSON. The shape mirrors wakeup.RoutinePayload:
-// {routine_id, variables, missed_ticks}. missed_ticks is set by the
-// cron tick when the catch-up cap collapsed N missed fires into one.
-func marshalRoutinePayload(routineID string, vars map[string]string, missedTicks int) (string, error) {
+// {routine_id, variables, missed_ticks, missed_since, missed_truncated}.
+// The three catch-up fields are set together, from gap, only when the
+// claim measured at least one missed tick under the summarize_missed
+// policy — they state the ticks counted and reported, never the ticks
+// fired (only one run is ever dispatched per claim).
+func marshalRoutinePayload(routineID string, vars map[string]string, gap *gapSummary) (string, error) {
body := map[string]any{
"routine_id": routineID,
}
if len(vars) > 0 {
body["variables"] = vars
}
- if missedTicks > 0 {
- body["missed_ticks"] = missedTicks
+ if gap != nil {
+ body["missed_ticks"] = gap.MissedTicks
+ body["missed_since"] = gap.FirstMissed.UTC().Format(time.RFC3339)
+ if gap.Truncated {
+ body["missed_truncated"] = true
+ }
}
b, err := json.Marshal(body)
if err != nil {
diff --git a/apps/backend/internal/office/routines/service_catchup_test.go b/apps/backend/internal/office/routines/service_catchup_test.go
new file mode 100644
index 0000000000..cd6bceed78
--- /dev/null
+++ b/apps/backend/internal/office/routines/service_catchup_test.go
@@ -0,0 +1,644 @@
+package routines_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/jmoiron/sqlx"
+ _ "github.com/mattn/go-sqlite3"
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+ "go.uber.org/zap/zaptest/observer"
+
+ "github.com/kandev/kandev/internal/common/logger"
+ "github.com/kandev/kandev/internal/office/models"
+ "github.com/kandev/kandev/internal/office/repository/sqlite"
+ "github.com/kandev/kandev/internal/office/routines"
+ "github.com/kandev/kandev/internal/office/wakeup"
+)
+
+// wrapRepo wraps a real routines.Repository (the sqlite implementation) and
+// lets individual tests force specific methods to fail, to exercise error
+// paths that are otherwise unreachable through the public service API
+// against a healthy in-memory database.
+type wrapRepo struct {
+ routines.Repository
+ failUpdateTriggerNextRun bool
+ failCreateRoutineRun bool
+}
+
+func (w *wrapRepo) UpdateTriggerNextRun(ctx context.Context, triggerID string, nextRunAt *time.Time) error {
+ if w.failUpdateTriggerNextRun {
+ return errors.New("simulated arm-write failure")
+ }
+ return w.Repository.UpdateTriggerNextRun(ctx, triggerID, nextRunAt)
+}
+
+func (w *wrapRepo) CreateRoutineRun(ctx context.Context, run *models.RoutineRun) error {
+ if w.failCreateRoutineRun {
+ return errors.New("simulated create-run failure")
+ }
+ return w.Repository.CreateRoutineRun(ctx, run)
+}
+
+// newWrappedTestRoutineService is newTestRoutineService plus access to the
+// wrapRepo (to flip failure switches) and the raw *sqlx.DB (to backdate
+// rows directly, as no service method can).
+func newWrappedTestRoutineService(t *testing.T) (*routines.RoutineService, *wrapRepo, *sqlx.DB) {
+ t.Helper()
+ db, err := sqlx.Open("sqlite3", ":memory:")
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ repo, err := sqlite.NewWithDB(db, db, nil)
+ if err != nil {
+ t.Fatalf("new repo: %v", err)
+ }
+ wrapped := &wrapRepo{Repository: repo}
+ return routines.NewRoutineService(wrapped, logger.Default(), &noopActivity{}), wrapped, db
+}
+
+func mustCreateCronRoutineAndTrigger(t *testing.T, svc *routines.RoutineService, taskTemplate string) *models.Routine {
+ t.Helper()
+ ctx := context.Background()
+ routine := &models.Routine{
+ WorkspaceID: "ws-1",
+ Name: "Cron routine",
+ TaskTemplate: taskTemplate,
+ AssigneeAgentProfileID: "agent-1",
+ Status: "active",
+ ConcurrencyPolicy: models.ConcurrencyPolicyAlwaysCreate,
+ }
+ if err := svc.CreateRoutine(ctx, routine); err != nil {
+ t.Fatalf("create routine: %v", err)
+ }
+ if err := svc.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ RoutineID: routine.ID, Kind: "cron", CronExpression: "* * * * *", Timezone: "UTC", Enabled: true,
+ }); err != nil {
+ t.Fatalf("create trigger: %v", err)
+ }
+ return routine
+}
+
+// TestProcessCronTrigger_ArmFailureAbortsDispatch covers AC-OFFICE-ROUTINE-CATCHUP-001.2:
+// when the re-arm write itself fails, the tick dispatches nothing for that
+// claim and no routine run is created.
+func TestProcessCronTrigger_ArmFailureAbortsDispatch(t *testing.T) {
+ svc, wrapped, _ := newWrappedTestRoutineService(t)
+ ctx := context.Background()
+ routine := mustCreateCronRoutineAndTrigger(t, svc, "")
+
+ wrapped.failUpdateTriggerNextRun = true
+ if err := svc.TickScheduledTriggers(ctx, time.Now().UTC().Add(2*time.Minute)); err != nil {
+ t.Fatalf("tick: %v", err)
+ }
+
+ runs, err := svc.ListRoutineRuns(ctx, routine.ID, 10, 0)
+ if err != nil {
+ t.Fatalf("list runs: %v", err)
+ }
+ if len(runs) != 0 {
+ t.Fatalf("routine runs = %d, want 0 (arm failure must abort dispatch entirely)", len(runs))
+ }
+}
+
+// TestDispatchRoutineRun_CreateRunFailureRecordsNothing covers AC-OFFICE-ROUTINE-CATCHUP-001.12:
+// when the run row itself cannot be created, dispatch returns an error and
+// no run row exists — there is nothing to mark failed.
+func TestDispatchRoutineRun_CreateRunFailureRecordsNothing(t *testing.T) {
+ svc, wrapped, _ := newWrappedTestRoutineService(t)
+ ctx := context.Background()
+ routine := mustCreateCronRoutineAndTrigger(t, svc, "")
+
+ wrapped.failCreateRoutineRun = true
+ if _, err := svc.FireManual(ctx, routine.ID, nil); err == nil {
+ t.Fatal("expected error from FireManual when CreateRoutineRun fails")
+ }
+
+ wrapped.failCreateRoutineRun = false
+ runs, err := svc.ListRoutineRuns(ctx, routine.ID, 10, 0)
+ if err != nil {
+ t.Fatalf("list runs: %v", err)
+ }
+ if len(runs) != 0 {
+ t.Fatalf("routine runs = %d, want 0", len(runs))
+ }
+}
+
+// TestProcessCronTrigger_LightweightMaterialiseFailureMarksRunFailed covers
+// AC-OFFICE-ROUTINE-CATCHUP-001.10: a lightweight routine's
+// CreateWakeupRequest failure (not an idempotency conflict) marks the
+// claim's run failed.
+func TestProcessCronTrigger_LightweightMaterialiseFailureMarksRunFailed(t *testing.T) {
+ svc := newTestRoutineService(t)
+ ctx := context.Background()
+ routine := newLightweightTestRoutine(t, svc)
+ if err := svc.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ RoutineID: routine.ID, Kind: "cron", CronExpression: "* * * * *", Timezone: "UTC", Enabled: true,
+ }); err != nil {
+ t.Fatalf("create trigger: %v", err)
+ }
+
+ svc.SetWakeupEnqueuer(&failingCreateWakeupEnqueuer{createErr: errors.New("simulated dispatcher failure")})
+
+ if err := svc.TickScheduledTriggers(ctx, time.Now().UTC().Add(2*time.Minute)); err != nil {
+ t.Fatalf("tick: %v", err)
+ }
+
+ runs, err := svc.ListRoutineRuns(ctx, routine.ID, 10, 0)
+ if err != nil {
+ t.Fatalf("list runs: %v", err)
+ }
+ if len(runs) != 1 {
+ t.Fatalf("routine runs = %d, want 1", len(runs))
+ }
+ if runs[0].Status != models.RoutineRunStatusFailed {
+ t.Errorf("status = %q, want failed", runs[0].Status)
+ }
+}
+
+// TestProcessCronTrigger_IdempotencyConflictDoesNotMarkRunFailed covers the
+// second sentence of AC-OFFICE-ROUTINE-CATCHUP-001.10: a wakeup request
+// refused as an idempotency-key duplicate is a successful dedup, not a
+// materialisation failure, and must not flip the run to failed.
+func TestProcessCronTrigger_IdempotencyConflictDoesNotMarkRunFailed(t *testing.T) {
+ svc := newTestRoutineService(t)
+ ctx := context.Background()
+ routine := newLightweightTestRoutine(t, svc)
+ if err := svc.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ RoutineID: routine.ID, Kind: "cron", CronExpression: "* * * * *", Timezone: "UTC", Enabled: true,
+ }); err != nil {
+ t.Fatalf("create trigger: %v", err)
+ }
+
+ svc.SetWakeupEnqueuer(&failingCreateWakeupEnqueuer{createErr: routines.ErrWakeupAlreadyRequested})
+
+ if err := svc.TickScheduledTriggers(ctx, time.Now().UTC().Add(2*time.Minute)); err != nil {
+ t.Fatalf("tick: %v", err)
+ }
+
+ runs, err := svc.ListRoutineRuns(ctx, routine.ID, 10, 0)
+ if err != nil {
+ t.Fatalf("list runs: %v", err)
+ }
+ if len(runs) != 1 {
+ t.Fatalf("routine runs = %d, want 1", len(runs))
+ }
+ // The concurrency-policy outcome (task_created, since nothing else was
+ // active) must survive — an idempotency dedup is not a failure.
+ if runs[0].Status == models.RoutineRunStatusFailed {
+ t.Error("status = failed, want the concurrency-policy outcome preserved (idempotency dedup is not a failure)")
+ }
+}
+
+// TestProcessCronTrigger_HeavyMaterialiseFailureMarksRunFailed covers the
+// heavy-path half of AC-OFFICE-ROUTINE-CATCHUP-001.10: a task-creation
+// failure marks the run failed instead of leaving it stuck at "received".
+func TestProcessCronTrigger_HeavyMaterialiseFailureMarksRunFailed(t *testing.T) {
+ svc := newTestRoutineService(t)
+ ctx := context.Background()
+ routine := mustCreateCronRoutineAndTrigger(t, svc, `{"title":"T","description":"D"}`)
+
+ svc.SetWorkflowEnsurer(&fakeWorkflowEnsurer{})
+ svc.SetTaskCreator(&failingTaskCreator{})
+
+ if err := svc.TickScheduledTriggers(ctx, time.Now().UTC().Add(2*time.Minute)); err != nil {
+ t.Fatalf("tick: %v", err)
+ }
+
+ runs, err := svc.ListRoutineRuns(ctx, routine.ID, 10, 0)
+ if err != nil {
+ t.Fatalf("list runs: %v", err)
+ }
+ if len(runs) != 1 {
+ t.Fatalf("routine runs = %d, want 1", len(runs))
+ }
+ if runs[0].Status != models.RoutineRunStatusFailed {
+ t.Errorf("status = %q, want failed (was stuck at received before this change)", runs[0].Status)
+ }
+}
+
+type failingTaskCreator struct{}
+
+func (f *failingTaskCreator) CreateOfficeTaskInWorkflow(
+ _ context.Context, _, _, _, _, _, _ string,
+) (string, error) {
+ return "", errors.New("simulated task creation failure")
+}
+
+// TestCreateRoutineTrigger_RejectsEmptyCronExpression covers the first half
+// of AC-OFFICE-ROUTINE-CATCHUP-001.8: a cron trigger must never be
+// persisted with a null next_run_at, so an empty expression is rejected
+// outright rather than silently created unarmed.
+func TestCreateRoutineTrigger_RejectsEmptyCronExpression(t *testing.T) {
+ svc := newTestRoutineService(t)
+ ctx := context.Background()
+ routine := createTestRoutine(t, svc, "AC-001.8 empty", "always_create")
+
+ err := svc.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ RoutineID: routine.ID, Kind: "cron", CronExpression: "", Timezone: "UTC", Enabled: true,
+ })
+ if err == nil {
+ t.Fatal("expected an error creating a cron trigger with an empty expression")
+ }
+
+ triggers, listErr := svc.ListRoutineTriggers(ctx, routine.ID)
+ if listErr != nil {
+ t.Fatalf("list triggers: %v", listErr)
+ }
+ if len(triggers) != 0 {
+ t.Fatalf("triggers = %d, want 0 (rejected trigger must not be persisted)", len(triggers))
+ }
+}
+
+// TestCreateRoutineTrigger_RejectsUnparseableCronExpression covers the
+// second half of AC-OFFICE-ROUTINE-CATCHUP-001.8: an expression that
+// shared.NextCronTime cannot parse is rejected at creation time rather than
+// persisted with a null next_run_at.
+func TestCreateRoutineTrigger_RejectsUnparseableCronExpression(t *testing.T) {
+ svc := newTestRoutineService(t)
+ ctx := context.Background()
+ routine := createTestRoutine(t, svc, "AC-001.8 unparseable", "always_create")
+
+ err := svc.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ RoutineID: routine.ID, Kind: "cron", CronExpression: "not a cron expression", Timezone: "UTC", Enabled: true,
+ })
+ if err == nil {
+ t.Fatal("expected an error creating a cron trigger with an unparseable expression")
+ }
+
+ triggers, listErr := svc.ListRoutineTriggers(ctx, routine.ID)
+ if listErr != nil {
+ t.Fatalf("list triggers: %v", listErr)
+ }
+ if len(triggers) != 0 {
+ t.Fatalf("triggers = %d, want 0 (rejected trigger must not be persisted)", len(triggers))
+ }
+}
+
+// TestTickScheduledTriggers_ReconciliationArmsStrandedTriggerWithoutDispatch
+// covers AC-OFFICE-ROUTINE-CATCHUP-001.9 at the service level: a trigger
+// claimed (next_run_at set to NULL) by a process that crashed before the
+// re-arm write landed gets armed by the next tick's reconciliation pass,
+// without dispatching a spurious run for the abandoned claim.
+func TestTickScheduledTriggers_ReconciliationArmsStrandedTriggerWithoutDispatch(t *testing.T) {
+ svc, wrapped, db := newWrappedTestRoutineService(t)
+ ctx := context.Background()
+ routine := mustCreateCronRoutineAndTrigger(t, svc, "")
+
+ triggers, err := svc.ListRoutineTriggers(ctx, routine.ID)
+ if err != nil || len(triggers) != 1 {
+ t.Fatalf("list triggers: %v (len=%d)", err, len(triggers))
+ }
+ triggerID := triggers[0].ID
+
+ // Simulate a claim that landed (next_run_at -> NULL) but whose re-arm
+ // write never happened, aged well past catchUpReclaimAfter.
+ staleUpdatedAt := time.Now().UTC().Add(-10 * time.Minute)
+ if _, err := wrapped.ClaimTrigger(ctx, triggerID, *triggers[0].NextRunAt); err != nil {
+ t.Fatalf("claim trigger: %v", err)
+ }
+ if _, err := db.ExecContext(ctx,
+ "UPDATE office_routine_triggers SET updated_at = ? WHERE id = ?", staleUpdatedAt, triggerID,
+ ); err != nil {
+ t.Fatalf("backdate updated_at: %v", err)
+ }
+
+ if err := svc.TickScheduledTriggers(ctx, time.Now().UTC()); err != nil {
+ t.Fatalf("tick: %v", err)
+ }
+
+ runs, err := svc.ListRoutineRuns(ctx, routine.ID, 10, 0)
+ if err != nil {
+ t.Fatalf("list runs: %v", err)
+ }
+ if len(runs) != 0 {
+ t.Fatalf("routine runs = %d, want 0 (reconciliation must never dispatch)", len(runs))
+ }
+
+ reArmed, err := svc.ListRoutineTriggers(ctx, routine.ID)
+ if err != nil {
+ t.Fatalf("list triggers: %v", err)
+ }
+ if reArmed[0].NextRunAt == nil {
+ t.Fatal("NextRunAt still nil after reconciliation, want armed")
+ }
+ if !reArmed[0].NextRunAt.After(time.Now().UTC().Add(-time.Minute)) {
+ t.Errorf("NextRunAt = %v, want a time close to/after now (re-armed from `now`, not from the stale claim)", *reArmed[0].NextRunAt)
+ }
+}
+
+// TestTickScheduledTriggers_MalformedExpressionRearmsWithDispatch verifies
+// that a legacy trigger with a malformed expression dispatches exactly one
+// run and is re-armed to the processing instant plus 24 hours
+// (AC-OFFICE-ROUTINE-CATCHUP-001.11), not left un-dispatched or re-armed to
+// its original due time. New trigger creation rejects this input before it
+// is persisted; this covers a row created before that validation existed.
+func TestTickScheduledTriggers_MalformedExpressionRearmsWithDispatch(t *testing.T) {
+ svc, wrapped, _ := newWrappedTestRoutineService(t)
+ ctx := context.Background()
+
+ routine := &models.Routine{
+ WorkspaceID: "ws-1", Name: "Bad cron", AssigneeAgentProfileID: "agent-1",
+ Status: "active", ConcurrencyPolicy: models.ConcurrencyPolicyAlwaysCreate,
+ }
+ if err := svc.CreateRoutine(ctx, routine); err != nil {
+ t.Fatalf("create routine: %v", err)
+ }
+ // Bypass CreateRoutineTrigger's own AC-001.8 rejection: seed a
+ // malformed-but-armed trigger directly through the repo, as a legacy
+ // row (created before AC-001.8) would look. Arm it in the past so it's
+ // due.
+ past := time.Now().UTC().Add(-time.Hour)
+ if err := wrapped.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ RoutineID: routine.ID, Kind: "cron", CronExpression: "garbage", Timezone: "UTC",
+ Enabled: true, NextRunAt: &past,
+ }); err != nil {
+ t.Fatalf("seed malformed trigger: %v", err)
+ }
+
+ before := time.Now().UTC()
+ if err := svc.TickScheduledTriggers(ctx, before); err != nil {
+ t.Fatalf("tick: %v", err)
+ }
+ after := time.Now().UTC()
+ runs, err := svc.ListRoutineRuns(ctx, routine.ID, 10, 0)
+ if err != nil {
+ t.Fatalf("list runs: %v", err)
+ }
+ if len(runs) != 1 {
+ t.Fatalf("routine runs after malformed trigger = %d, want 1", len(runs))
+ }
+ triggers, err := svc.ListRoutineTriggers(ctx, routine.ID)
+ if err != nil {
+ t.Fatalf("list triggers: %v", err)
+ }
+ if len(triggers) != 1 || triggers[0].NextRunAt == nil {
+ t.Fatalf("triggers = %+v, want one re-armed trigger", triggers)
+ }
+ got := *triggers[0].NextRunAt
+ if got.Before(before.Add(24*time.Hour)) || got.After(after.Add(24*time.Hour)) {
+ t.Fatalf("next_run_at = %v, want within [%v, %v] (processing instant + 24h)",
+ got, before.Add(24*time.Hour), after.Add(24*time.Hour))
+ }
+}
+
+// TestTickScheduledTriggers_MalformedExpressionLogsUnderlyingError covers
+// AC-OFFICE-ROUTINE-CATCHUP-001.11: the warning emitted when the elapsed-tick
+// computation fails must name the underlying cron-parse error, not just the
+// trigger ID and expression.
+func TestTickScheduledTriggers_MalformedExpressionLogsUnderlyingError(t *testing.T) {
+ core, logs := observer.New(zapcore.WarnLevel)
+ log, err := logger.NewFromZap(zap.New(core))
+ if err != nil {
+ t.Fatalf("create observer logger: %v", err)
+ }
+
+ db, err := sqlx.Open("sqlite3", ":memory:")
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ repo, err := sqlite.NewWithDB(db, db, nil)
+ if err != nil {
+ t.Fatalf("new repo: %v", err)
+ }
+ svc := routines.NewRoutineService(repo, log, &noopActivity{})
+
+ ctx := context.Background()
+ routine := &models.Routine{
+ WorkspaceID: "ws-1", Name: "Bad cron", AssigneeAgentProfileID: "agent-1",
+ Status: "active", ConcurrencyPolicy: models.ConcurrencyPolicyAlwaysCreate,
+ }
+ if err := svc.CreateRoutine(ctx, routine); err != nil {
+ t.Fatalf("create routine: %v", err)
+ }
+ past := time.Now().UTC().Add(-time.Hour)
+ if err := repo.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ RoutineID: routine.ID, Kind: "cron", CronExpression: "garbage", Timezone: "UTC",
+ Enabled: true, NextRunAt: &past,
+ }); err != nil {
+ t.Fatalf("seed malformed trigger: %v", err)
+ }
+
+ if err := svc.TickScheduledTriggers(ctx, time.Now().UTC()); err != nil {
+ t.Fatalf("tick: %v", err)
+ }
+
+ entries := logs.FilterMessage("compute routine catch-up failed").All()
+ if len(entries) == 0 {
+ t.Fatal("expected a 'compute routine catch-up failed' warning")
+ }
+ if errVal, ok := entries[0].ContextMap()["error"]; !ok || errVal == "" {
+ t.Fatalf("warning did not name the underlying error, context = %#v", entries[0].ContextMap())
+ }
+}
+
+// TestTickScheduledTriggers_GapExceedingCapProducesExactlyOneRun covers
+// AC-OFFICE-ROUTINE-CATCHUP-001.1 and 001.3 together at the service level:
+// a gap spanning far more ticks than catch_up_max produces exactly one
+// run and one wakeup dispatch on resume — catch_up_max bounds only the
+// counted tick total (here capped at 3), never the number of runs created.
+func TestTickScheduledTriggers_GapExceedingCapProducesExactlyOneRun(t *testing.T) {
+ svc, _, db := newWrappedTestRoutineService(t)
+ ctx := context.Background()
+
+ routine := &models.Routine{
+ WorkspaceID: "ws-1", Name: "Gapped", AssigneeAgentProfileID: "agent-1",
+ Status: "active", ConcurrencyPolicy: models.ConcurrencyPolicyAlwaysCreate,
+ CatchUpPolicy: models.CatchUpPolicySummarizeMissed, CatchUpMax: 3,
+ }
+ if err := svc.CreateRoutine(ctx, routine); err != nil {
+ t.Fatalf("create routine: %v", err)
+ }
+ if err := svc.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ RoutineID: routine.ID, Kind: "cron", CronExpression: "* * * * *", Timezone: "UTC", Enabled: true,
+ }); err != nil {
+ t.Fatalf("create trigger: %v", err)
+ }
+ // The backend was "down" far longer than catch_up_max ticks can count.
+ longDown := time.Now().UTC().Add(-100 * time.Minute)
+ triggers, err := svc.ListRoutineTriggers(ctx, routine.ID)
+ if err != nil || len(triggers) != 1 {
+ t.Fatalf("list triggers: %v (len=%d)", err, len(triggers))
+ }
+ if _, err := db.ExecContext(ctx,
+ "UPDATE office_routine_triggers SET next_run_at = ? WHERE id = ?", longDown, triggers[0].ID,
+ ); err != nil {
+ t.Fatalf("backdate next_run_at: %v", err)
+ }
+
+ enq := &fakeWakeupEnqueuer{}
+ svc.SetWakeupEnqueuer(enq)
+
+ if err := svc.TickScheduledTriggers(ctx, time.Now().UTC()); err != nil {
+ t.Fatalf("tick: %v", err)
+ }
+
+ runs, err := svc.ListRoutineRuns(ctx, routine.ID, 10, 0)
+ if err != nil {
+ t.Fatalf("list runs: %v", err)
+ }
+ if len(runs) != 1 {
+ t.Fatalf("routine runs = %d, want exactly 1 regardless of gap size", len(runs))
+ }
+ if len(enq.dispatched) != 1 {
+ t.Fatalf("wakeup dispatches = %d, want exactly 1", len(enq.dispatched))
+ }
+ run := runs[0]
+ if run.CatchUpMissedTicks == nil {
+ t.Fatal("CatchUpMissedTicks = nil, want set")
+ }
+ if *run.CatchUpMissedTicks != routine.CatchUpMax-1 {
+ t.Errorf("CatchUpMissedTicks = %d, want %d (catch_up_max - 1)", *run.CatchUpMissedTicks, routine.CatchUpMax-1)
+ }
+ if !run.CatchUpTruncated {
+ t.Error("CatchUpTruncated = false, want true")
+ }
+ if run.CatchUpFirstMissedAt == nil || !run.CatchUpFirstMissedAt.Equal(longDown) {
+ t.Errorf("CatchUpFirstMissedAt = %v, want %v", run.CatchUpFirstMissedAt, longDown)
+ }
+
+ // AC-OFFICE-ROUTINE-CATCHUP-002.5: the gap must also be readable from
+ // the actual wakeup-request payload dispatched to the agent, not only
+ // from the run's DB columns.
+ if len(enq.created) != 1 {
+ t.Fatalf("wakeup requests created = %d, want exactly 1", len(enq.created))
+ }
+ var payload wakeup.RoutinePayload
+ if err := wakeup.UnmarshalPayload(enq.created[0].Payload, &payload); err != nil {
+ t.Fatalf("unmarshal wakeup payload: %v", err)
+ }
+ if payload.MissedTicks != routine.CatchUpMax-1 {
+ t.Errorf("payload MissedTicks = %d, want %d", payload.MissedTicks, routine.CatchUpMax-1)
+ }
+ if !payload.MissedTruncated {
+ t.Error("payload MissedTruncated = false, want true")
+ }
+ if payload.MissedSince != longDown.UTC().Format(time.RFC3339) {
+ t.Errorf("payload MissedSince = %q, want %q", payload.MissedSince, longDown.UTC().Format(time.RFC3339))
+ }
+}
+
+// TestTickScheduledTriggers_HeavyRoutineGapLeavesTaskUnmodified covers
+// AC-OFFICE-ROUTINE-CATCHUP-002.6: a heavy routine dispatched across a
+// measured gap must render its task title/description exactly as the
+// template does, with the gap readable only through the routine run, not
+// injected into the task the agent sees.
+func TestTickScheduledTriggers_HeavyRoutineGapLeavesTaskUnmodified(t *testing.T) {
+ svc, _, db := newWrappedTestRoutineService(t)
+ ctx := context.Background()
+
+ routine := &models.Routine{
+ WorkspaceID: "ws-1", Name: "Heavy gapped", AssigneeAgentProfileID: "agent-1",
+ TaskTemplate: `{"title":"Daily review","description":"Fixed description"}`,
+ Status: "active",
+ ConcurrencyPolicy: models.ConcurrencyPolicyAlwaysCreate,
+ CatchUpPolicy: models.CatchUpPolicySummarizeMissed, CatchUpMax: 25,
+ }
+ if err := svc.CreateRoutine(ctx, routine); err != nil {
+ t.Fatalf("create routine: %v", err)
+ }
+ if err := svc.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ RoutineID: routine.ID, Kind: "cron", CronExpression: "* * * * *", Timezone: "UTC", Enabled: true,
+ }); err != nil {
+ t.Fatalf("create trigger: %v", err)
+ }
+ longDown := time.Now().UTC().Add(-10 * time.Minute)
+ triggers, err := svc.ListRoutineTriggers(ctx, routine.ID)
+ if err != nil || len(triggers) != 1 {
+ t.Fatalf("list triggers: %v (len=%d)", err, len(triggers))
+ }
+ if _, err := db.ExecContext(ctx,
+ "UPDATE office_routine_triggers SET next_run_at = ? WHERE id = ?", longDown, triggers[0].ID,
+ ); err != nil {
+ t.Fatalf("backdate next_run_at: %v", err)
+ }
+
+ wf := &fakeWorkflowEnsurer{}
+ tc := &fakeTaskCreator{}
+ svc.SetWorkflowEnsurer(wf)
+ svc.SetTaskCreator(tc)
+
+ if err := svc.TickScheduledTriggers(ctx, time.Now().UTC()); err != nil {
+ t.Fatalf("tick: %v", err)
+ }
+
+ if tc.captured.title != "Daily review" {
+ t.Errorf("task title = %q, want exactly %q (unmodified by the gap)", tc.captured.title, "Daily review")
+ }
+ if tc.captured.description != "Fixed description" {
+ t.Errorf("task description = %q, want exactly %q (unmodified by the gap)",
+ tc.captured.description, "Fixed description")
+ }
+
+ runs, err := svc.ListRoutineRuns(ctx, routine.ID, 10, 0)
+ if err != nil {
+ t.Fatalf("list runs: %v", err)
+ }
+ if len(runs) != 1 {
+ t.Fatalf("routine runs = %d, want 1", len(runs))
+ }
+ run := runs[0]
+ if run.CatchUpMissedTicks == nil || *run.CatchUpMissedTicks < 1 {
+ t.Fatalf("CatchUpMissedTicks = %v, want set and >= 1 (gap summary readable via the run)", run.CatchUpMissedTicks)
+ }
+ if run.CatchUpFirstMissedAt == nil || !run.CatchUpFirstMissedAt.Equal(longDown) {
+ t.Errorf("CatchUpFirstMissedAt = %v, want %v", run.CatchUpFirstMissedAt, longDown)
+ }
+ if run.LinkedTaskID != "task-routine-1" {
+ t.Errorf("LinkedTaskID = %q, want task-routine-1 (heavy dispatch happened)", run.LinkedTaskID)
+ }
+}
+
+// TestFireManual_RecordsNoGapSummaryDespitePendingTriggerGap covers
+// AC-OFFICE-ROUTINE-CATCHUP-002.12: gap measurement applies only to a cron
+// trigger's own armed claim. A manual fire on a routine whose cron trigger
+// has a large backdated next_run_at (i.e. would produce a gap on its own
+// tick) must still record no gap summary at all.
+func TestFireManual_RecordsNoGapSummaryDespitePendingTriggerGap(t *testing.T) {
+ svc, _, db := newWrappedTestRoutineService(t)
+ ctx := context.Background()
+
+ routine := &models.Routine{
+ WorkspaceID: "ws-1", Name: "Manual despite gap", AssigneeAgentProfileID: "agent-1",
+ Status: "active", ConcurrencyPolicy: models.ConcurrencyPolicyAlwaysCreate,
+ CatchUpPolicy: models.CatchUpPolicySummarizeMissed, CatchUpMax: 25,
+ }
+ if err := svc.CreateRoutine(ctx, routine); err != nil {
+ t.Fatalf("create routine: %v", err)
+ }
+ if err := svc.CreateRoutineTrigger(ctx, &models.RoutineTrigger{
+ RoutineID: routine.ID, Kind: "cron", CronExpression: "* * * * *", Timezone: "UTC", Enabled: true,
+ }); err != nil {
+ t.Fatalf("create trigger: %v", err)
+ }
+ triggers, err := svc.ListRoutineTriggers(ctx, routine.ID)
+ if err != nil || len(triggers) != 1 {
+ t.Fatalf("list triggers: %v (len=%d)", err, len(triggers))
+ }
+ longDown := time.Now().UTC().Add(-100 * time.Minute)
+ if _, err := db.ExecContext(ctx,
+ "UPDATE office_routine_triggers SET next_run_at = ? WHERE id = ?", longDown, triggers[0].ID,
+ ); err != nil {
+ t.Fatalf("backdate next_run_at: %v", err)
+ }
+
+ run, err := svc.FireManual(ctx, routine.ID, nil)
+ if err != nil {
+ t.Fatalf("fire manual: %v", err)
+ }
+ if run.CatchUpMissedTicks != nil {
+ t.Errorf("CatchUpMissedTicks = %v, want nil (manual fire records no gap)", *run.CatchUpMissedTicks)
+ }
+ if run.CatchUpFirstMissedAt != nil {
+ t.Errorf("CatchUpFirstMissedAt = %v, want nil", *run.CatchUpFirstMissedAt)
+ }
+ if run.CatchUpTruncated {
+ t.Error("CatchUpTruncated = true, want false")
+ }
+}
diff --git a/apps/backend/internal/office/routines/service_cron_advance_failure_test.go b/apps/backend/internal/office/routines/service_cron_advance_failure_test.go
index b7e0a69439..ab8e9c262a 100644
--- a/apps/backend/internal/office/routines/service_cron_advance_failure_test.go
+++ b/apps/backend/internal/office/routines/service_cron_advance_failure_test.go
@@ -98,9 +98,14 @@ func TestTickScheduledTriggers_UnsatisfiableExpression_DisarmsInsteadOfLooping(t
// TestTickScheduledTriggers_RecoverableCatchUpFailure_RearmsForRetry is a
// regression test for a catch-up failure that is NOT expression
// unsatisfiability (e.g. the timezone database is transiently unavailable).
-// Unlike the unsatisfiable case, this must re-arm next_run_at to the
-// original due time so the next tick retries once the underlying issue
-// clears, instead of leaving the trigger permanently disarmed.
+// Unlike the unsatisfiable case (AC-OFFICE-ROUTINE-CATCHUP-001.11 governs
+// only "elapsed-tick computation fails", never a syntactically-impossible
+// expression), this arms next_run_at to the processing instant plus 24
+// hours and still dispatches exactly one run for the claim — REQ-001's
+// "one resume, one wake" is unconditional, and AC-001.11 says as much
+// ("shall therefore dispatch at most one run per day until it is deleted
+// and recreated") — rather than leaving the trigger permanently disarmed
+// or silently skipping the claim it already took.
func TestTickScheduledTriggers_RecoverableCatchUpFailure_RearmsForRetry(t *testing.T) {
db, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
@@ -146,11 +151,13 @@ func TestTickScheduledTriggers_RecoverableCatchUpFailure_RearmsForRetry(t *testi
enq := &fakeWakeupEnqueuer{}
svc.SetWakeupEnqueuer(enq)
- if err := svc.TickScheduledTriggers(ctx, time.Now().UTC()); err != nil {
+ before := time.Now().UTC()
+ if err := svc.TickScheduledTriggers(ctx, before); err != nil {
t.Fatalf("tick scheduled triggers: %v", err)
}
- if len(enq.created) != 0 {
- t.Fatalf("expected no dispatch on a recoverable catch-up failure, got %d", len(enq.created))
+ after := time.Now().UTC()
+ if len(enq.created) != 1 {
+ t.Fatalf("expected exactly one dispatch on a recoverable catch-up failure (AC-001.11), got %d", len(enq.created))
}
triggers, err := repo.ListTriggersByRoutineID(ctx, routine.ID)
@@ -161,9 +168,11 @@ func TestTickScheduledTriggers_RecoverableCatchUpFailure_RearmsForRetry(t *testi
t.Fatalf("expected 1 trigger, got %d", len(triggers))
}
if triggers[0].NextRunAt == nil {
- t.Fatalf("next_run_at = nil, want re-armed to the original due time so the trigger retries")
+ t.Fatalf("next_run_at = nil, want re-armed to processing instant + 24h")
}
- if !triggers[0].NextRunAt.Equal(due) {
- t.Fatalf("next_run_at = %v, want re-armed to original due time %v", *triggers[0].NextRunAt, due)
+ got := *triggers[0].NextRunAt
+ if got.Before(before.Add(24*time.Hour)) || got.After(after.Add(24*time.Hour)) {
+ t.Fatalf("next_run_at = %v, want within [%v, %v] (processing instant + 24h)",
+ got, before.Add(24*time.Hour), after.Add(24*time.Hour))
}
}
diff --git a/apps/backend/internal/office/routines/test_helpers.go b/apps/backend/internal/office/routines/test_helpers.go
new file mode 100644
index 0000000000..23066aec3b
--- /dev/null
+++ b/apps/backend/internal/office/routines/test_helpers.go
@@ -0,0 +1,18 @@
+package routines
+
+import "time"
+
+// MarshalRoutinePayloadForTest exposes marshalRoutinePayload to external
+// test packages so cross-package tests (e.g. the prompt builder's gap
+// rendering, AC-OFFICE-ROUTINE-CATCHUP-002.5) can round-trip through the
+// real wakeup-request wire format instead of a hand-written JSON literal
+// that could silently drift from what dispatch actually sends.
+func MarshalRoutinePayloadForTest(
+ routineID string, vars map[string]string, missedTicks int, firstMissed time.Time, truncated bool,
+) (string, error) {
+ var gap *gapSummary
+ if missedTicks > 0 {
+ gap = &gapSummary{MissedTicks: missedTicks, FirstMissed: firstMissed, Truncated: truncated}
+ }
+ return marshalRoutinePayload(routineID, vars, gap)
+}
diff --git a/apps/backend/internal/office/routines/wakeup_enqueue_fail_disposition_test.go b/apps/backend/internal/office/routines/wakeup_enqueue_fail_disposition_test.go
index e43eefa250..86232ec3ac 100644
--- a/apps/backend/internal/office/routines/wakeup_enqueue_fail_disposition_test.go
+++ b/apps/backend/internal/office/routines/wakeup_enqueue_fail_disposition_test.go
@@ -13,9 +13,15 @@ import (
// generic (non-ErrWakeupAlreadyRequested) error, but still lets Dispatch/
// FailWakeupRequest succeed trivially — they are never reached once
// CreateWakeupRequest itself fails.
-type failingCreateWakeupEnqueuer struct{ fakeWakeupEnqueuer }
+type failingCreateWakeupEnqueuer struct {
+ fakeWakeupEnqueuer
+ createErr error
+}
func (f *failingCreateWakeupEnqueuer) CreateWakeupRequest(context.Context, *routines.WakeupRequest) error {
+ if f.createErr != nil {
+ return f.createErr
+ }
return errors.New("wakeup store unavailable")
}
diff --git a/apps/backend/internal/office/service/catchup_prompt_test.go b/apps/backend/internal/office/service/catchup_prompt_test.go
new file mode 100644
index 0000000000..e656805692
--- /dev/null
+++ b/apps/backend/internal/office/service/catchup_prompt_test.go
@@ -0,0 +1,143 @@
+package service_test
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kandev/kandev/internal/office/routines"
+ "github.com/kandev/kandev/internal/office/service"
+ "github.com/kandev/kandev/internal/office/shared"
+)
+
+// routineCatchUpReasons are the three reasons AC-OFFICE-ROUTINE-CATCHUP-002.5
+// requires the gap to render under. Event is included specifically because
+// PromoteRunAndCoalesceWakeupIfQueued can rewrite a cron-created run's
+// reason to it after the gap was already measured and stored — a
+// Cron-only gate would silently drop that gap.
+var routineCatchUpReasons = []string{
+ shared.RunReasonRoutineDispatchCron,
+ shared.RunReasonRoutineDispatchEvent,
+ shared.RunReasonRoutineDispatch,
+}
+
+// TestBuildPrompt_RoutineGap_RendersUnderEachRoutineReason covers
+// AC-OFFICE-ROUTINE-CATCHUP-002.5: a run whose ContextSnapshot carries a
+// measured gap renders a wake-context line under all three routine-dispatch
+// reasons, including the count and the first-missed timestamp, and states
+// the count is a lower bound when truncated.
+func TestBuildPrompt_RoutineGap_RendersUnderEachRoutineReason(t *testing.T) {
+ svc := newTestService(t)
+ ctx := context.Background()
+
+ firstMissed := time.Date(2026, 5, 10, 11, 55, 0, 0, time.UTC)
+ snapshot, err := routines.MarshalRoutinePayloadForTest("r-1", nil, 5, firstMissed, true)
+ if err != nil {
+ t.Fatalf("marshal routine payload: %v", err)
+ }
+
+ for _, reason := range routineCatchUpReasons {
+ t.Run(reason, func(t *testing.T) {
+ pc := service.BuildPromptContextWithSnapshotForTest(svc, ctx, reason, "{}", snapshot)
+ if pc.MissedTicks != 5 {
+ t.Fatalf("MissedTicks = %d, want 5", pc.MissedTicks)
+ }
+ if pc.MissedSince != "2026-05-10T11:55:00Z" {
+ t.Errorf("MissedSince = %q, want 2026-05-10T11:55:00Z", pc.MissedSince)
+ }
+ if !pc.MissedTruncated {
+ t.Error("MissedTruncated = false, want true")
+ }
+
+ prompt := service.BuildPrompt(pc)
+ if !strings.Contains(prompt, "missed 5") {
+ t.Errorf("prompt = %q, want it to state the missed-tick count", prompt)
+ }
+ if !strings.Contains(prompt, "tick") {
+ t.Errorf("prompt = %q, want it to describe missed ticks, not runs (a tick never becomes a run)", prompt)
+ }
+ if strings.Contains(prompt, "run") {
+ t.Errorf("prompt = %q, want no mention of a missed run — only one run is ever dispatched", prompt)
+ }
+ if !strings.Contains(prompt, "2026-05-10T11:55:00Z") {
+ t.Errorf("prompt = %q, want it to state the first-missed timestamp", prompt)
+ }
+ if !strings.Contains(prompt, "lower bound") {
+ t.Errorf("prompt = %q, want it to state the count is a lower bound (truncated)", prompt)
+ }
+ })
+ }
+}
+
+// TestBuildPrompt_RoutineGap_NoSummaryRendersNoStatement covers AC-002.3:
+// when the ContextSnapshot carries no gap (a merely-late tick, or a policy
+// that opts out), the run's reason still gates on being a routine reason,
+// but no missed-tick statement is rendered at all.
+func TestBuildPrompt_RoutineGap_NoSummaryRendersNoStatement(t *testing.T) {
+ svc := newTestService(t)
+ ctx := context.Background()
+
+ pc := service.BuildPromptContextWithSnapshotForTest(
+ svc, ctx, shared.RunReasonRoutineDispatchCron, "{}", `{"routine_id":"r-1"}`,
+ )
+ if pc.MissedTicks != 0 {
+ t.Fatalf("MissedTicks = %d, want 0", pc.MissedTicks)
+ }
+ prompt := service.BuildPrompt(pc)
+ if strings.Contains(prompt, "missed") {
+ t.Errorf("prompt = %q, want no missed-tick statement", prompt)
+ }
+}
+
+// TestBuildPrompt_RoutineGap_LegacyPayloadWithoutMissedSinceIsIgnored covers
+// the upgrade path: a wakeup request queued by a pre-catch-up build wrote
+// only {"routine_id":...,"missed_ticks":N} — missed_since did not exist yet
+// — and can sit undispatched across a deploy. Decoding that legacy shape
+// must not populate the catch-up fields from an incomplete payload, or the
+// rendered prompt states a tick count with no timestamp ("since .").
+func TestBuildPrompt_RoutineGap_LegacyPayloadWithoutMissedSinceIsIgnored(t *testing.T) {
+ svc := newTestService(t)
+ ctx := context.Background()
+
+ legacySnapshot := `{"routine_id":"r1","missed_ticks":5}`
+ pc := service.BuildPromptContextWithSnapshotForTest(svc, ctx, shared.RunReasonRoutineDispatchCron, "{}", legacySnapshot)
+ if pc.MissedTicks != 0 {
+ t.Fatalf("MissedTicks = %d, want 0 (a legacy payload with no missed_since must not populate the gap fields)", pc.MissedTicks)
+ }
+ if pc.MissedSince != "" {
+ t.Errorf("MissedSince = %q, want empty", pc.MissedSince)
+ }
+
+ prompt := service.BuildPrompt(pc)
+ if strings.Contains(prompt, "missed") {
+ t.Errorf("prompt = %q, want no missed-tick statement for a legacy payload missing missed_since", prompt)
+ }
+ if strings.Contains(prompt, "since .") {
+ t.Errorf("prompt = %q, must never render a malformed empty timestamp", prompt)
+ }
+}
+
+// TestBuildPrompt_RoutineGap_NonRoutineReasonIgnoresSnapshot proves the
+// decode is gated on the run's reason: a non-routine reason with the exact
+// same context_snapshot renders nothing, keeping every other run's prompt
+// byte-identical to before this parameter existed.
+func TestBuildPrompt_RoutineGap_NonRoutineReasonIgnoresSnapshot(t *testing.T) {
+ svc := newTestService(t)
+ ctx := context.Background()
+
+ snapshot, err := routines.MarshalRoutinePayloadForTest(
+ "r-1", nil, 5, time.Date(2026, 5, 10, 11, 55, 0, 0, time.UTC), true,
+ )
+ if err != nil {
+ t.Fatalf("marshal routine payload: %v", err)
+ }
+ pc := service.BuildPromptContextWithSnapshotForTest(svc, ctx, service.RunReasonHeartbeat, "{}", snapshot)
+ if pc.MissedTicks != 0 {
+ t.Fatalf("MissedTicks = %d, want 0 (non-routine reason must not decode the snapshot)", pc.MissedTicks)
+ }
+ prompt := service.BuildPrompt(pc)
+ if strings.Contains(prompt, "missed 5") {
+ t.Errorf("prompt = %q, want no missed-tick statement for a non-routine reason", prompt)
+ }
+}
diff --git a/apps/backend/internal/office/service/prompt_builder.go b/apps/backend/internal/office/service/prompt_builder.go
index 543ad0a018..0a43114c26 100644
--- a/apps/backend/internal/office/service/prompt_builder.go
+++ b/apps/backend/internal/office/service/prompt_builder.go
@@ -89,6 +89,16 @@ type PromptContext struct {
// renders metadata + the fetch-tool name so the agent can call
// get_task_document_kandev to read content.
HandoffContext *v1.TaskContext
+
+ // Routine catch-up gap fields. Populated by buildPromptContext only
+ // when the run's reason is one of the three routine-dispatch reasons
+ // and its ContextSnapshot decodes a wakeup.RoutinePayload carrying a
+ // gap (AC-OFFICE-ROUTINE-CATCHUP-002.5). MissedTicks is 0 when no gap
+ // was measured or reported for this run's claim, in which case
+ // BuildPrompt renders no wake-context line at all.
+ MissedTicks int
+ MissedSince string
+ MissedTruncated bool
}
// BuildPrompt generates a structured prompt for a run reason.
@@ -127,6 +137,7 @@ func BuildPrompt(pc *PromptContext) string {
prompt = fmt.Sprintf("You have been woken for reason: %s.", pc.Reason)
}
prompt = appendOneTimeInstructions(prompt, pc.OneTimeInstructions)
+ prompt = appendMissedTicksSection(prompt, pc)
prompt = appendHandoffSection(prompt, pc.HandoffContext)
return appendRuntimeContext(prompt, pc)
}
@@ -141,6 +152,27 @@ func appendOneTimeInstructions(prompt, instructions string) string {
return prompt + "\n\n## One-time workflow move instructions\n\n" + instructions
}
+// appendMissedTicksSection renders the gap a routine's cron tick measured
+// for this run's claim, when one was recorded (AC-OFFICE-ROUTINE-CATCHUP-002.5).
+// Renders nothing when MissedTicks is 0 — the no-gap-summary case (walk
+// failed, policy is skip_missed, catch_up_max is 1, or the tick was merely
+// late with no missed ticks) must produce no missed-tick statement at all
+// (AC-002.3), and a manual or webhook fire never sets MissedTicks in the
+// first place (AC-002.12).
+func appendMissedTicksSection(prompt string, pc *PromptContext) string {
+ if pc == nil || pc.MissedTicks <= 0 {
+ return prompt
+ }
+ if !strings.HasSuffix(prompt, "\n") {
+ prompt += "\n"
+ }
+ line := fmt.Sprintf("\nYou missed %d scheduled tick(s) since %s.", pc.MissedTicks, pc.MissedSince)
+ if pc.MissedTruncated {
+ line += " This count is a lower bound (more ticks were missed than could be counted)."
+ }
+ return prompt + line
+}
+
// appendHandoffSection renders the office task-handoffs context block
// (Related tasks / Documents available / Workspace) onto every run
// prompt that carries a HandoffContext. Document bodies are
diff --git a/apps/backend/internal/office/service/scheduler_integration.go b/apps/backend/internal/office/service/scheduler_integration.go
index 920f724c50..c7d913955a 100644
--- a/apps/backend/internal/office/service/scheduler_integration.go
+++ b/apps/backend/internal/office/service/scheduler_integration.go
@@ -14,6 +14,7 @@ import (
"github.com/kandev/kandev/internal/office/pause"
officeruntime "github.com/kandev/kandev/internal/office/runtime"
"github.com/kandev/kandev/internal/office/shared"
+ "github.com/kandev/kandev/internal/office/wakeup"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -458,7 +459,7 @@ func (si *SchedulerIntegration) assembleAgentPrompt(
runCtx officeruntime.RunContext,
instructionsDir, agentsMD string,
) string {
- pc := si.buildPromptContext(ctx, run.Reason, run.Payload)
+ pc := si.buildPromptContext(ctx, run.Reason, run.Payload, run.ContextSnapshot)
pc.RunID = runCtx.RunID
pc.AgentID = runCtx.AgentID
pc.SessionID = runCtx.SessionID
@@ -1036,13 +1037,18 @@ func (si *SchedulerIntegration) resolveExecutorForRun(
return si.svc.ResolveExecutor(ctx, "", agent.ID, projectID, "")
}
-// buildPromptContext assembles a PromptContext from run data.
+// buildPromptContext assembles a PromptContext from run data. contextSnapshot
+// is run.ContextSnapshot — decoded as a wakeup.RoutinePayload only when
+// reason is one of the three routine-dispatch reasons, so every other run's
+// prompt stays byte-identical to before this parameter existed
+// (AC-OFFICE-ROUTINE-CATCHUP-002.5).
func (si *SchedulerIntegration) buildPromptContext(
- ctx context.Context, reason, payload string,
+ ctx context.Context, reason, payload, contextSnapshot string,
) *PromptContext {
parsed := ParseRunPayload(payload)
pc := &PromptContext{Reason: reason}
pc.OneTimeInstructions = parsed[RunPayloadOneTimeInstructionsKey]
+ applyRoutineCatchUpContext(pc, reason, contextSnapshot)
if taskID := parsed["task_id"]; taskID != "" {
si.enrichTaskContext(ctx, pc, taskID)
@@ -1089,6 +1095,35 @@ func (si *SchedulerIntegration) buildPromptContext(
return pc
}
+// applyRoutineCatchUpContext decodes contextSnapshot as a
+// wakeup.RoutinePayload and copies its catch-up fields onto pc, but only
+// when reason is one of the three routine-dispatch reasons. Gating on all
+// three, not just RunReasonRoutineDispatchCron, matters:
+// PromoteRunAndCoalesceWakeupIfQueued can rewrite an in-flight run's reason
+// to RunReasonRoutineDispatchEvent after a cron claim already measured and
+// stored the gap, so a Cron-only gate would silently drop it
+// (AC-OFFICE-ROUTINE-CATCHUP-002.5). A manual or webhook fire never writes
+// these fields, so gating the other two reasons this way is safe: there is
+// nothing to decode for them. Any run with a different reason is left
+// byte-identical to before this function existed.
+func applyRoutineCatchUpContext(pc *PromptContext, reason, contextSnapshot string) {
+ switch reason {
+ case shared.RunReasonRoutineDispatchCron, shared.RunReasonRoutineDispatchEvent, shared.RunReasonRoutineDispatch:
+ default:
+ return
+ }
+ var routinePayload wakeup.RoutinePayload
+ if err := wakeup.UnmarshalPayload(contextSnapshot, &routinePayload); err != nil {
+ return
+ }
+ if routinePayload.MissedTicks <= 0 || routinePayload.MissedSince == "" {
+ return
+ }
+ pc.MissedTicks = routinePayload.MissedTicks
+ pc.MissedSince = routinePayload.MissedSince
+ pc.MissedTruncated = routinePayload.MissedTruncated
+}
+
// enrichHandoffContext populates pc.HandoffContext from the office
// task-handoffs context API so the run prompt's BuildPrompt can render
// the Related-tasks / Documents-available / Workspace section.
diff --git a/apps/backend/internal/office/service/test_helpers.go b/apps/backend/internal/office/service/test_helpers.go
index 4e295f94c3..58e34b89d0 100644
--- a/apps/backend/internal/office/service/test_helpers.go
+++ b/apps/backend/internal/office/service/test_helpers.go
@@ -67,7 +67,17 @@ func ParseRateLimitResetTimeForTest(errMsg string, now time.Time) *time.Time {
// This is exposed so integration tests in the _test package can verify prompt building.
func BuildPromptContextForTest(svc *Service, ctx context.Context, reason, payload string) *PromptContext {
si := &SchedulerIntegration{svc: svc, logger: svc.logger}
- return si.buildPromptContext(ctx, reason, payload)
+ return si.buildPromptContext(ctx, reason, payload, "")
+}
+
+// BuildPromptContextWithSnapshotForTest is BuildPromptContextForTest plus
+// contextSnapshot (run.ContextSnapshot), the run-reason-gated source for
+// the routine catch-up gap fields (AC-OFFICE-ROUTINE-CATCHUP-002.5). A
+// separate helper, rather than extending BuildPromptContextForTest's
+// signature, so its nine existing callers stay untouched.
+func BuildPromptContextWithSnapshotForTest(svc *Service, ctx context.Context, reason, payload, contextSnapshot string) *PromptContext {
+ si := &SchedulerIntegration{svc: svc, logger: svc.logger}
+ return si.buildPromptContext(ctx, reason, payload, contextSnapshot)
}
// CoalesceRoutineWakeupForTest creates a wakeup-request carrying the
diff --git a/apps/backend/internal/office/wakeup/payloads.go b/apps/backend/internal/office/wakeup/payloads.go
index 57727de521..3064b0d28e 100644
--- a/apps/backend/internal/office/wakeup/payloads.go
+++ b/apps/backend/internal/office/wakeup/payloads.go
@@ -56,15 +56,20 @@ type AgentErrorPayload struct {
// (free-form JSON) so the dispatcher / scriptengine can render them
// into the prompt at run-build time.
//
-// MissedTicks is set by the routines cron tick when the catch-up cap
-// collapsed N missed fires into one. Zero on the happy path; non-zero
-// signals "you missed N ticks since the last fire" — the prompt
-// builder surfaces it in the wake context so the agent can decide
-// whether the gap matters.
+// MissedTicks, MissedSince and MissedTruncated are the gap this claim
+// measured, written together only when the routine's cron tick counted at
+// least one missed tick under the summarize_missed policy
+// (docs/specs/office/requirements/routine-catch-up.md,
+// AC-OFFICE-ROUTINE-CATCHUP-002.1). They state the ticks counted and
+// reported, never the ticks fired — exactly one run is ever dispatched per
+// claim. All three are omitempty, so a gapless fire's payload is
+// byte-identical to one with no catch-up fields at all.
type RoutinePayload struct {
- RoutineID string `json:"routine_id"`
- Variables map[string]any `json:"variables,omitempty"`
- MissedTicks int `json:"missed_ticks,omitempty"`
+ RoutineID string `json:"routine_id"`
+ Variables map[string]any `json:"variables,omitempty"`
+ MissedTicks int `json:"missed_ticks,omitempty"`
+ MissedSince string `json:"missed_since,omitempty"`
+ MissedTruncated bool `json:"missed_truncated,omitempty"`
}
// SelfPayload is the wakeup-request payload for an agent-initiated
diff --git a/apps/web/app/office/routines/[id]/routine-detail-view.test.tsx b/apps/web/app/office/routines/[id]/routine-detail-view.test.tsx
new file mode 100644
index 0000000000..17073dbf4e
--- /dev/null
+++ b/apps/web/app/office/routines/[id]/routine-detail-view.test.tsx
@@ -0,0 +1,109 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen, fireEvent, within } from "@testing-library/react";
+import type { Routine, RoutineTrigger } from "@/lib/state/slices/office/types";
+
+vi.mock("@/lib/routing/client-router", () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ refresh: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ prefetch: vi.fn(),
+ }),
+}));
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: unknown) => unknown) =>
+ selector({
+ workspaces: { activeId: "ws-1" },
+ office: { agentProfilesByWorkspaceId: { "ws-1": [] } },
+ }),
+}));
+
+vi.mock("@/lib/api/domains/office-api", () => ({
+ updateRoutine: vi.fn(),
+ runRoutine: vi.fn(),
+ createRoutineTrigger: vi.fn(),
+ deleteRoutineTrigger: vi.fn(),
+}));
+
+vi.mock("@/lib/toast/sonner", () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}));
+
+import { RoutineDetailView } from "./routine-detail-view";
+
+afterEach(() => cleanup());
+
+const BASE_ROUTINE: Routine = {
+ id: "routine-1",
+ workspaceId: "ws-1",
+ name: "Nightly digest",
+ taskTemplate: {},
+ status: "active",
+ concurrencyPolicy: "coalesce_if_active",
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: "2026-01-01T00:00:00Z",
+};
+
+const NO_TRIGGERS: RoutineTrigger[] = [];
+
+// AC-OFFICE-ROUTINE-CATCHUP-003.8: the catch_up_max control renders under
+// the summarizing policy and is absent under skip_missed, in the detail
+// view exactly as in the create dialog. Regression coverage for the
+// enqueue_missed_with_cap -> summarize_missed rename, which silently
+// dropped this control when only the option values/defaults were updated
+// without also updating the two "===" guards that decide whether the
+// input renders at all.
+describe("RoutineDetailView catch-up max control (AC-003.8)", () => {
+ it("renders the catch-up max input when the routine is summarize_missed", () => {
+ render(
+
- {t("office:beyondThisCountMissedTicksAre")} + {t("office:beyondThisCountMissedTicksAreNotCounted")}