Skip to content

fix(office): stop routines from bricking after their first fire - #3488

Merged
carlosflorencio merged 4 commits into
kdlbs:mainfrom
nova28:feature/office-routine-runs-bbv
Sep 8, 2026
Merged

fix(office): stop routines from bricking after their first fire#3488
carlosflorencio merged 4 commits into
kdlbs:mainfrom
nova28:feature/office-routine-runs-bbv

Conversation

@nova28

@nova28 nova28 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Tip

PR walkthrough: Open the visual walkthrough

Today: A routine fires successfully once and then never fires again, silently, forever. The pre-installed "Coordinator heartbeat" routine self-bricked after its first fire: 323 consecutive coalesces over 28 days, zero done/failed/cancelled rows ever written.
After this: A routine's gate self-heals once its linked task reaches a terminal state, so the 2nd, 3rd, ... Nth fire actually launches a run again under both skip_if_active and coalesce_if_active. Lightweight (taskless) fires no longer get stuck claiming a task was created, and routines.last_run_at is finally populated.
Who hits this: Anyone relying on a recurring routine — lightweight or heavy — to fire more than once. A heavy routine with a static title was silently exposed to the same wall on its second fire; only a routine whose title happens to interpolate {{datetime}} (like the built-in "WO conductor tick") was accidentally immune, because that made every fire's fingerprint unique.
Scope: standalone bug fix, no sibling PRs.
Not here: the general TaskMoved event gap (to_step_name is never populated by either production publisher) — it's a pre-existing issue that also affects blocker/parent finalization, not just routines. Left the event-driven SyncRunStatus wiring in place as a harmless no-op that becomes live plumbing if that gap is ever fixed elsewhere, but the actual repair here is a pull-based self-heal that reads the task's terminal status directly and doesn't depend on it.

A single hardcoded status check (GetActiveRunForFingerprint looking only for status = 'task_created', with no age bound and no join to the task's real state) treated a routine's first successful fire as permanently "still in flight." Nothing ever moved a run out of task_created, so it blocked every later fire with the same dispatch fingerprint.

Important Changes

  • Heavy-routine concurrency gate (applyConcurrencyPolicy) now self-heals inline: if the active run's linked task has reached a terminal state (read directly from the shared tasks table via a new GetTaskTerminalStatus), the run is closed out and the gate clears for this fire — independent of the dead TaskMoved event path.
  • Lightweight (taskless) routine fires no longer write task_created; an idempotency-conflict dedup now resolves to done instead of failed.
  • New atomic UpdateRunStatusIfTaskCreated (WHERE status = 'task_created') makes the event-driven path and the gate's own self-heal race-safe against each other.
  • routines.last_run_at is now written on every fire.
  • The existing 7-day notBefore age bound remains the backstop for a crashed dispatch that never reaches a terminal task state.

Validation

Backend/Go only — no apps/web/ files changed, so Playwright E2E is not required per the repo's exemption rule.

  • go build ./..., go vet ./... — clean.
  • go test ./internal/office/... ./internal/backendapp/... — all packages ok.
  • gofmt -l on all 10 changed files — clean.
  • golangci-lint run ./... --new-from-rev=<merge-base> --allow-parallel-runners — 0 issues; full-repo golangci-lint run ./... --allow-parallel-runners — 0 issues.
  • make fmt, make typecheck, make lint (backend + web + harness + specs + architecture) — all clean.
  • New regression test TestDispatch_HeavyRoutine_GateSelfHealsWhenTaskTerminatesWithoutEvent (both skip_if_active/coalesce_if_active subtests) drives the real production call chain (FireManualdispatchRoutineRunapplyConcurrencyPolicy), marks the linked task COMPLETED via raw SQL with no call to SyncRunStatus, and asserts the second fire produces a task_created run instead of being skipped/coalesced. Verified as a genuine regression test by temporarily neutralizing the fix and confirming both subtests fail on the exact original symptom shape, then reverting.
  • Also added: TestDispatch_LightweightRoutine_IdempotencyConflictIsNotFailed, TestGetTaskTerminalStatus, TestUpdateRunStatusIfTaskCreated, TestGetRoutineRunByLinkedTaskID_EmptyTaskIDGuarded.
  • Full CGO_ENABLED=1 go test -tags fts5 ./...: every internal/office/** and internal/backendapp* package ok. A handful of unrelated packages fail with a macOS tmpdir-path/resource-contention signature that touches none of this diff's files; independently reproduced identically against the merge-base in a scratch worktree, so treated as a declared pre-existing gap, not a blocker.
  • Postgres: internal/office/repository/sqlite/routines.go changes are plain parameterized ?/Rebind SQL — no ALTER TABLE, dialect branch, table rebuild, or SQLite-only syntax, and no migration files touched. KANDEV_TEST_POSTGRES_DSN was unset in this environment — declared gap.
  • pnpm run i18n:ratchet — no-op, no UI files touched.

Possible Improvements

Low risk. computeFingerprint still keys on the routine title, so two heavy routines with identical static titles remain indistinguishable by dispatch fingerprint — out of scope here since the reported symptom is fixed by the terminal-state self-heal regardless of fingerprint collisions; worth a dedicated look if routine authoring conventions change.

Checklist

  • If I do not have repository write access and this is a large architectural change, I discussed the direction in a linked issue before opening this PR.
  • This PR contains one logical change; unrelated work is split into separate PRs.
  • I have performed a self-review of my code.
  • I have manually tested my changes and they work as expected.
  • My changes have tests that cover the new functionality and edge cases.
  • If my change touches UI files (apps/web/), I have added or updated Playwright e2e tests in apps/web/e2e/ and verified them with make test-e2e.
  • I checked whether this affects public docs in docs/public/** and updated them or noted why no docs change is needed.

Review in cubic

…hook

Review round 1 found the heavy-routine half of the routine-runs fix was
dead in production: events.TaskMoved never carries to_step_name, so
SyncRunStatus was never reached and heavy runs still gated on
task_created until the 7-day backstop. The concurrency gate now
self-heals by reading the linked task's real terminal state directly
(applyConcurrencyPolicy -> selfHealIfTaskTerminal), independent of that
event. Also stops recording a deduped lightweight fire as failed
(ErrWakeupIdempotencyConflict is success by another fire, not a
failure), and guards SyncRunStatus/GetRoutineRunByLinkedTaskID against
an empty taskID matching an arbitrary lightweight run.
@nova28
nova28 temporarily deployed to opencode-review-trusted September 8, 2026 03:52 — with GitHub Actions Inactive
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @nova28's task in 5m 15s —— View job


Findings

Suggestion (recommended, doesn't block)

  1. Comment typo: unclosed quote in two doc commentsroutines/service.go:843, repository/sqlite/routines.go:354

    • Both SyncRunStatus and GetRoutineRunByLinkedTaskID doc comments write linked_task_id defaults to " — the closing quote is missing. Should read defaults to "". Inline suggestions posted.
  2. Deleted task is indistinguishable from in-progress task in selfHealIfTaskTerminalroutines/service.go:775

    • GetTaskTerminalStatus returns ("", nil) for both a task that exists-but-is-not-terminal and a task row that was deleted. selfHealIfTaskTerminal treats both identically (fail closed), keeping the gate engaged for up to 7 days. This is the correct defensive default, but a deleted linked task will silently lock the routine for the full TTL with no log line. Adding a distinct return value for "task missing" (or a debug log at the missing-row path) would make stalled routines observable before the TTL fires. Inline comment with details posted.

Summary

Severity Count
Blocker 0
Suggestion 2

Verdict: Ready to merge

The root cause analysis is correct and the fix is well-reasoned. The inline selfHealIfTaskTerminal pull-based check cleanly addresses the event-path gap (to_step_name never populated in production) without depending on it. The UpdateRunStatusIfTaskCreated conditional-WHERE approach correctly races both the event-driven path and the gate's own self-heal so exactly one caller wins. The new TouchRoutineLastRun writer is appropriately narrow (avoids clobbering concurrent config edits that UpdateRoutine would overwrite). Test coverage is thorough: the regression test drives the full production call chain, temporarily neutralizes the fix to confirm the symptom shape, and covers both concurrency policies.

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

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

⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Advanced

Run ID: 149bc0fa-7f3e-4352-a2b5-74bf75e2c58f

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Advanced

Run ID: db1056f4-a0b8-4d3d-b4d5-9787794527bb

📥 Commits

Reviewing files that changed from the base of the PR and between 3d042e9 and 904f1cc.

📒 Files selected for processing (10)
  • apps/backend/internal/backendapp/adapters_office.go
  • apps/backend/internal/backendapp/main.go
  • apps/backend/internal/office/repository/sqlite/blockers.go
  • apps/backend/internal/office/repository/sqlite/routines.go
  • apps/backend/internal/office/repository/sqlite/routines_test.go
  • apps/backend/internal/office/routines/service.go
  • apps/backend/internal/office/routines/service_test.go
  • apps/backend/internal/office/service/event_subscribers.go
  • apps/backend/internal/office/service/routine_run_sync_test.go
  • apps/backend/internal/office/service/service.go

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Routine runs now correctly finish when linked tasks are completed or cancelled.
    • Repeated wake-up requests are handled safely without reporting a failure.
    • Stalled routine runs can recover after interruptions and no longer block future runs indefinitely.
    • Lightweight routines now report their final status immediately.
  • Improvements
    • Routine activity timestamps and run status are updated more reliably.
    • Task completion and cancellation are reflected in associated routine runs.

Walkthrough

Routine runs now finalize lightweight dispatches immediately, ignore idempotency races as successful dispatches, recover stale heavy runs, and close linked runs when tasks reach terminal steps. SQLite adds repository operations for these lifecycle transitions.

Changes

Routine run lifecycle

Layer / File(s) Summary
Run storage and repository contracts
apps/backend/internal/office/repository/sqlite/..., apps/backend/internal/office/routines/service.go, apps/backend/internal/office/repository/sqlite/routines_test.go
The repository filters stale active runs, reads task terminal states, updates last_run_at, and atomically closes task_created runs.
Routine dispatch lifecycle
apps/backend/internal/office/routines/service.go, apps/backend/internal/office/routines/service_test.go, apps/backend/internal/backendapp/adapters_office.go
Lightweight runs finalize as done or failed. Idempotency conflicts finalize as done. Heavy runs self-heal after seven days or when linked tasks are terminal.
Office task completion wiring
apps/backend/internal/office/service/service.go, apps/backend/internal/office/service/event_subscribers.go, apps/backend/internal/office/service/routine_run_sync_test.go, apps/backend/internal/backendapp/main.go
The office service calls the routine syncer with done or cancelled when linked tasks reach terminal steps. Application wiring registers the routine service.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 904f1

No concrete merge-blocking risk remains in the current changes.

Sequence Diagram(s)

sequenceDiagram
  participant TaskEvent
  participant OfficeService
  participant RoutineService
  participant SQLiteRepository
  TaskEvent->>OfficeService: task.moved to terminal step
  OfficeService->>RoutineService: SyncRunStatus(taskID, done or cancelled)
  RoutineService->>SQLiteRepository: find linked routine run
  RoutineService->>SQLiteRepository: conditionally close task_created run
  SQLiteRepository-->>RoutineService: close result
  RoutineService-->>OfficeService: synchronization result
Loading

Suggested reviewers: zeval, carlosflorencio

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 28 functions across 10 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main fix: preventing recurring office routines from becoming permanently blocked after their first fire.
Description check ✅ Passed The description includes the required summary, important changes, validation, possible improvements, and checklist. It provides detailed technical context and test results. The summary is longer than …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

❤️ Share

A rabbit reads each line,
The patch grows clear beneath the moon,
Small changes hop in place,
Tests guard the garden path,
Reviews bloom before the dawn.

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

@greptile-apps

greptile-apps Bot commented Sep 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds pull-based reconciliation between routine runs and linked task state, makes lightweight runs terminal immediately, wires event-driven run synchronization, records last_run_at, and bounds active-run lookup by age.

  • Adds atomic close-out and linked-task status repository operations.
  • Reopens heavy-routine gates after completed or cancelled tasks.
  • Finalizes taskless runs and translates wakeup idempotency conflicts.
  • Adds regression coverage for repeated fires, synchronization, expiration, and timestamps.
  • Still mishandles failed tasks, long-running tasks, and wakeup dispatch errors.

Confidence Score: 2/5

The PR is not safe to merge because failed tasks can still block recurring routines, long-running tasks can lose concurrency protection, and failed wakeup dispatches are recorded as successful.

The terminal-state lookup omits a production-reachable terminal state, the age filter bypasses skip/coalesce for known active tasks after seven days, and lightweight dispatch failures can strand queued work while persisting done; the explicit production-comment requirement must also be satisfied.

Files Needing Attention: apps/backend/internal/office/repository/sqlite/routines.go; apps/backend/internal/office/routines/service.go

Important Files Changed

Filename Overview
apps/backend/internal/office/routines/service.go Implements routine self-healing and lightweight finalization, but expires live gates, records failed wakeup dispatches as done, and includes comments that violate repository guidance.
apps/backend/internal/office/repository/sqlite/routines.go Adds active-run expiration and task-state reconciliation, but omits the canonical terminal FAILED state and can ignore still-active old tasks.
apps/backend/internal/office/service/event_subscribers.go Adds best-effort routine-run synchronization when terminal task-move events contain the required data.
apps/backend/internal/backendapp/main.go Correctly wires the routine synchronizer before Office event subscribers are registered.
apps/backend/internal/office/routines/service_test.go Adds substantial regression coverage, though it does not cover failed linked tasks, live tasks older than the cutoff, or dispatch errors leaving queued wakeups.
apps/backend/internal/office/repository/sqlite/routines_test.go Covers the new repository operations but encodes expiration without checking a still-active linked task.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Fire[Routine fire] --> Gate{Concurrency gate}
  Gate -->|active run found| TaskState{Linked task state}
  TaskState -->|completed/cancelled| Close[Atomically close prior run]
  TaskState -->|failed, currently missed| Block[Skip or coalesce until age cutoff]
  TaskState -->|non-terminal but older than 7 days| Duplicate[Gate bypassed; duplicate task]
  Close --> Materialize
  Gate -->|no active run| Materialize{Routine type}
  Materialize -->|heavy| Task[Create linked task]
  Materialize -->|lightweight| Enqueue[Create wakeup request]
  Enqueue --> Dispatch{Dispatch succeeds?}
  Dispatch -->|yes| Done[Mark run done]
  Dispatch -->|no, currently| FalseDone[Request remains queued; mark run done]
Loading

Reviews (1): Last reviewed commit: "fix(office): close the routine gate with..." | Re-trigger Greptile

Comment thread apps/backend/internal/office/repository/sqlite/routines.go
Comment thread apps/backend/internal/office/repository/sqlite/routines.go Outdated
Comment thread apps/backend/internal/office/routines/service.go

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 904f1cc252

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

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

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

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/backend/internal/office/repository/sqlite/routines.go
Comment thread apps/backend/internal/office/routines/service.go Outdated
Comment thread apps/backend/internal/office/routines/service.go
Comment thread apps/backend/internal/office/repository/sqlite/routines.go
Comment thread apps/backend/internal/office/routines/service.go
@carlosflorencio
carlosflorencio self-requested a review September 8, 2026 07:05
…res honestly

PR review found two gaps in the routine-gate self-heal: GetTaskTerminalStatus
didn't recognize a FAILED linked task as terminal, permanently gating the
routine like the original bug; and a lightweight fire whose wakeup Dispatch
call failed was still recorded as "done" even though nothing ever retries a
stuck queued wakeup-request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nova28
nova28 temporarily deployed to opencode-review-trusted September 8, 2026 07:36 — with GitHub Actions Inactive
@carlosflorencio
carlosflorencio temporarily deployed to opencode-review-trusted September 8, 2026 07:54 — with GitHub Actions Inactive
@carlosflorencio

Copy link
Copy Markdown
Member

Thanks for the detailed review. I pushed 7a692e0df3 on top of the contributor's latest commit.

The update keeps long-running tasks gated without an age cutoff, reconciles completed, failed, cancelled, archived, and missing linked tasks, checks older active runs, gives manual and webhook fires source-specific idempotency keys, records dispatch failures as failed, adds lookup indexes, and updates the scheduler specifications. Focused Office/backend race tests and specification lint pass.

@carlosflorencio
carlosflorencio merged commit dff6543 into kdlbs:main Sep 8, 2026
74 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants