Skip to content

feat: add Loop lifecycle controls and surfaces (stack 2/3) - #301

Closed
pedronauck wants to merge 5 commits into
mainfrom
stack/loop-lifecycle-v2-02-controls-surfaces
Closed

pedronauck wants to merge 5 commits into
mainfrom
stack/loop-lifecycle-v2-02-controls-surfaces

Conversation

@pedronauck

@pedronauck pedronauck commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

Stack context

This is 2/3 of native GitHub stack #303 and depends on #300.

Layer Pull request Base CodeRabbit-visible files
1. Runtime foundations #300 main 184
2. Controls and public surfaces #301 (this PR) #300 253
3. Web, docs, and QA #302 #301 227

Review and merge from the bottom upward. This stack replaces the oversized #294 review surface.

Summary

This layer turns the runtime foundations from #300 into a complete operator and agent control plane. It adds long-running supervision, cooperative cancel versus immediate kill, pause/resume and durable waits, admission deduplication, then co-ships the public contract through HTTP, UDS, CLI, native tools, OpenAPI, and generated TypeScript types.

The key outcome is parity: no lifecycle capability is trapped behind an internal Go call or Web-only control.

What changes

Long-running supervision

  • Removes hidden wall-clock death for legitimate days-long nodes.
  • Tracks progress and silence separately from process/session liveness.
  • Detects provider/session death and resumes from committed progress under bounded repair policy.
  • Adds truthful canceled and live watching run states without coercing them to failed or running.

Cancel is not kill

  • cancel is cooperative: the request becomes durable, delivery is observable, and the node/run drains to canceled(operator_cancel).
  • kill immediately stops the bound session and ends at canceled(operator_kill).
  • Run and node verbs are idempotent; races select one winner and return structured deterministic loser information.
  • The former Loop stop verb, routes, schemas, and transition cause are removed as a hard cut.

Pause, resume, waits, and admission

  • Supports drain and immediate pause modes with durable pause state.
  • Supports normal resume, immediate resume, and attempt-reset resume under explicit policy.
  • Persists timer, approval, and event waits across daemon restarts without holding a lease while parked.
  • Adds escalation state and watch-event admission claims with expiry and deduplication.
  • Reconciles due waits, retries, admission claims, and event gaps through the scheduler backstop.

Public and agent-manageable surfaces

  • Extends HTTP and UDS routes for run/node pause, resume, cancel, kill, quarantine, and requeue.
  • Extends compozy loop CLI commands with structured output and the same validation/error model.
  • Registers eight Loop lifecycle native tools with canonical IDs, descriptors, schemas, risk flags, capability gates, and availability diagnostics.
  • Regenerates OpenAPI, API reference content, and TypeScript contract types from daemon-owned truth.
  • Returns structured conflict answers carrying actual state, allowed transitions, winner identity, reason, and request time.

Public contract notes

  • Run state vocabulary contains 12 values: queued, running, watching, needs-approval, paused, done, no-op, blocked, failed, exhausted, stalled, and canceled.
  • Terminal causes distinguish cooperative cancellation from immediate kill.
  • Public reads never infer or coerce a friendlier state than the daemon committed.
  • Unknown retired stop operations fail rather than silently aliasing to cancel or kill.

Verification

  • Generated OpenAPI/TypeScript artifacts pass codegen drift checks.
  • internal/tools/builtin passed its race-enabled canonical catalog suite: 270 tests, including new tool IDs, output schemas, risk, digest, and capability assertions.
  • Daemon workspace-access integration passed all 8 matrix cases after the rebase conflict resolution.
  • The final make gate-full is intentionally deferred until after CodeRabbit remediation. No final green-gate claim is made here.

Compozy Impact Audit

  • Native tools: adds and registers the Loop lifecycle tool set for pause/resume/cancel/kill/requeue operations, including descriptors, input/output schemas, risk flags, availability diagnostics, capability gates, and HTTP/CLI fallbacks. Retired stop behavior is deleted.
  • Extensibility and hooks: lifecycle events and effect hooks are reachable through the public contract; extension IDs, bundles, bridge SDKs, MCP sidecars, and unrelated registries remain unchanged. Config values added in feat: add Loop lifecycle runtime foundations (stack 1/3) #300 now have public documentation/schema exposure.
  • Workspace data isolation: every CLI/HTTP/UDS/native request resolves workspace ownership before reading or mutating run/node state. Contract, core, store, event, and diagnostic paths retain workspace IDs; neighbor reads and foreign mutations are rejected.
  • Official Compozy skill: the behavior change requires a bundled skill update, delivered in feat: complete Loop lifecycle Web, docs, and QA (stack 3/3) #302 after the public IDs and CLI paths in this layer are final.

Reviewer guide

Suggested order: liveness state machine → cancel/kill race rules → pause/wait persistence → admission dedupe → API contract → CLI/native parity. Please focus on whether every public surface reaches the same validator/writer and whether conflict responses remain deterministic after a fresh read.

Stack created with the official GitHub Stacks CLI.

Summary by CodeRabbit

  • New Features
    • Added Loop run cancellation and termination controls.
    • Added node-level pause, resume, cancel, kill, and requeue actions.
    • Added paginated Loop node inventory with filtering.
    • Added durable waits, approval escalation, retry details, and lifecycle event reporting.
    • Added duplicate-admission suppression and confirmed-death action recovery.
  • Changes
    • Replaced the Loop “stop” action with explicit cancel and kill operations.
    • Added a canceled run status and richer structured error details.
    • Added watch event-key validation and normalization.
  • Bug Fixes
    • Improved parked-run timing, liveness detection, wait recovery, and stale coordinator handling.

@greptile-apps

greptile-apps Bot commented Aug 3, 2026 •

Copy link
Copy Markdown

Too many files changed for review. (296 files found, 100 file limit)

Bypass the limit by tagging @greptile-apps to review.

@vercel

vercel Bot commented Aug 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
compozy-site Ready Ready Preview Aug 4, 2026 12:02am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Loop lifecycle platform

Layer / File(s) Summary
Lifecycle contracts and state model
internal/loop/*, internal/api/contract/*
Added canceled status, transition causes, node controls, waits, admission identities, lifecycle metadata, liveness data, and death-resume contracts.
Coordinator and persistence workflows
internal/loop/*, internal/store/globaldb/*, internal/daemon/*
Added cancellation, pause/resume, requeue, wait recovery, wait escalation, admission suppression, liveness recording, death-resume continuation, and parent-close processing.
API, CLI, tools, and SDK surfaces
internal/api/*, internal/cli/*, internal/tools/*, sdk/*
Replaced run stop operations with cancel and kill operations. Added node lifecycle operations, node inventory, structured mutation responses, and event-key validation.
Web integration and validation coverage
web/*, internal/*_test.go, sdk/*_test.go
Updated web controls, hooks, formatters, fixtures, route contracts, integration tests, and lifecycle output assertions.
Orchestration timeout removal
internal/config/*, internal/daemon/*
Removed the task orchestration action-run timeout configuration and changed liveness handling to use lease timing, session activity, silence windows, and confirmed-death recovery.

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

Possibly related PRs

  • compozy/compozy#295: Extends the same Loop lifecycle runtime foundation with cancellation, controls, waits, retries, and events.
  • compozy/compozy#300: Shares Loop lifecycle enums, coordinator behavior, waits, retries, and persistence changes.
  • compozy/compozy#302: Overlaps in Loop lifecycle APIs, node inventory, cancellation, pause/resume, requeue, and web controls.

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant WebOrCLI
  participant LoopAPI
  participant LoopService
  participant GlobalDB
  participant Coordinator
  Operator->>WebOrCLI: Select cancel, kill, pause, resume, or node action
  WebOrCLI->>LoopAPI: Send lifecycle request
  LoopAPI->>LoopService: Pass validated identity and actor context
  LoopService->>GlobalDB: Persist mutation and update lifecycle state
  GlobalDB->>Coordinator: Reserve or wake coordinator work
  Coordinator-->>LoopService: Return committed lifecycle state
  LoopService-->>LoopAPI: Return structured mutation response
  LoopAPI-->>WebOrCLI: Return response
  WebOrCLI-->>Operator: Display lifecycle result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: adding Loop lifecycle controls and public surfaces.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch stack/loop-lifecycle-v2-02-controls-surfaces

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

❤️ Share

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

@github-actions

github-actions Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit 05fd58d.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 20

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (5)
internal/api/core/loops_test.go (1)

1996-2007: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Strengthen assertLoopOKMutation to verify the echoed run_id/node_id.

assertLoopOKMutation only decodes and checks the ok field. The stub handlers for cancel/kill/pause/resume/requeue deliberately echo RunID and NodeID back in LoopMutationResponse (see happyLoopService at Lines 1569-1595), but no call site (Lines 255-281) checks these values. A routing bug that swaps or drops run_id/node_id on the way to the service would still report ok: true and pass this test.

Decode and assert the expected run_id (and node_id, when applicable) in assertLoopOKMutation, or add a variant that accepts expected IDs.

As per path instructions, "Ensure tests verify behavior outcomes, not just function calls."

🧪 Proposed strengthening
-func assertLoopOKMutation(t *testing.T, engine http.Handler, method string, path string, body []byte) {
+func assertLoopOKMutation(t *testing.T, engine http.Handler, method string, path string, body []byte, wantRunID string, wantNodeID string) {
 	t.Helper()

 	resp := performRequest(t, engine, method, path, body)
 	assertLoopStatus(t, resp.Code, http.StatusOK, resp.Body.String())
 	var payload struct {
-		OK bool `json:"ok"`
+		OK     bool   `json:"ok"`
+		RunID  string `json:"run_id"`
+		NodeID string `json:"node_id,omitempty"`
 	}
 	testutil.DecodeJSONResponse(t, resp, &payload)
-	if !payload.OK {
+	if !payload.OK || payload.RunID != wantRunID || (wantNodeID != "" && payload.NodeID != wantNodeID) {
 		t.Fatalf("%s %s payload = %#v, want ok=true", method, path, payload)
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/core/loops_test.go` around lines 1996 - 2007, Strengthen
assertLoopOKMutation to decode the echoed run_id and node_id from
LoopMutationResponse and compare them with the IDs expected for each mutation
request. Update its call sites for cancel, kill, pause, resume, and requeue to
supply the expected identifiers, allowing node_id to be omitted only where the
operation does not include one, while retaining the existing ok=true assertion.

Source: Path instructions

internal/loop/compiler.go (1)

337-369: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add test coverage for wait-control event filter compilation.

This new branch is not covered by internal/loop tests. Add tests for compileWatchEventsFilters in the wait-control path, including when params.Event is nil and when node.Params.Decode(&params) returns a decode error.

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

In `@internal/loop/compiler.go` around lines 337 - 369, Add internal/loop tests
covering compileWatchEventsFilters for control-wait nodes: verify a nil
params.Event returns successfully without compiling a filter, and verify a
node.Params.Decode failure returns the expected wrapped error. Reuse the
existing test helpers and fixtures for node parameters, lintContext, and
ResolvedDefinition.
internal/automation/dispatch_loop.go (1)

110-134: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Expose loop-run suppression in automation dispatch results.

LoopStartResult.Suppressed and SuppressedCount are available from the loop starter, but dispatchLoopBackedAttempt only propagates RunID. A suppressed duplicate loop fire should be recorded in scheduledRun.Metadata or emitted through the post-fire hook, so operators can distinguish it from a new loop fire.

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

In `@internal/automation/dispatch_loop.go` around lines 110 - 134, The dispatch
path in dispatchLoopBackedAttempt currently ignores LoopStartResult.Suppressed
and SuppressedCount after StartLoop succeeds. Propagate these fields into
scheduledRun.Metadata or the existing post-fire hook, preserving RunID handling,
so suppressed duplicate fires and their counts are distinguishable from newly
started loops.
internal/daemon/loop_action_liveness.go (1)

230-257: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not treat a stalled session as fresh liveness evidence.

Status() preserves stalled liveness metadata with StallState: "stalled" even when Liveness.Activity == nil, so a stopped-but-still-registered session still returns evidence = true in refreshActionProgress. This resets the silence timer and can prevent SilenceAfter/death-streak detection from firing. Treat present-but-inactive stall metadata as evidence = false, or only update progressAt from real Liveness.Activity.LastActivityAt movement.

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

In `@internal/daemon/loop_action_liveness.go` around lines 230 - 257, Update
refreshActionProgress to return evidence=false when session liveness metadata
indicates a stalled state without active Liveness.Activity. Ensure only real
Activity.LastActivityAt movement updates usage.progressAt and counts as fresh
evidence, preserving the existing behavior for active activity and missing
session data.
internal/daemon/native_loop_tools_support.go (1)

230-245: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove the duplicate TerminalRun mapping.

ReasonCodeTerminalRun and ReasonCodeRunTerminal both use the terminal_loop_run reason code, and the existing terminal-kill case already maps ReasonCodeTerminalRun to toolspkg.ReasonLoopTerminalRun. Map ReasonCodeRunTerminal to the same tool reason as terminal run rejections rather than adding a separate ReasonLoopRunTerminal entry.

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

In `@internal/daemon/native_loop_tools_support.go` around lines 230 - 245, Update
the reason mapping switch to map ReasonCodeRunTerminal to
toolspkg.ReasonLoopTerminalRun, matching the existing ReasonCodeTerminalRun
mapping; remove the separate ReasonLoopRunTerminal mapping while preserving all
other cases.
🟡 Minor comments (16)
internal/loop/coordinator_action_metadata.go-68-71 (1)

68-71: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Separate the unknown-kind error from the incomplete-provenance error.

The condition mixes two distinct failures. An unrecognized continuation_kind produces the message "death-resume continuation provenance is incomplete", which points the operator at missing fields instead of the invalid kind.

🐛 Proposed fix
-	if meta.ContinuationKind != deathResumeContinuationKind || meta.ResumeFromTaskRunID == "" ||
-		meta.ResumeFromSessionID == "" {
+	if meta.ContinuationKind != deathResumeContinuationKind {
+		return fmt.Errorf(
+			"%w: action continuation kind is invalid: %q",
+			ErrValidation,
+			meta.ContinuationKind,
+		)
+	}
+	if meta.ResumeFromTaskRunID == "" || meta.ResumeFromSessionID == "" {
 		return fmt.Errorf("%w: death-resume continuation provenance is incomplete", ErrValidation)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/loop/coordinator_action_metadata.go` around lines 68 - 71, Update
the validation around meta.ContinuationKind to report an unknown continuation
kind separately from incomplete death-resume provenance. First reject
unrecognized kinds with an error identifying the invalid kind, then validate
ResumeFromTaskRunID and ResumeFromSessionID only for death-resume continuations
while preserving the existing ErrValidation wrapping.
internal/api/contract/loop_runs.go-86-91 (1)

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

Normalize node_controls and waits before building LoopRunResponse.

LoopRunResponse sends both fields on every response, but they should use empty arrays instead of null. This affects API consumers when the store has no controls or waits for a run.

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

In `@internal/api/contract/loop_runs.go` around lines 86 - 91, Normalize the node
controls and waits collections to non-nil empty slices before constructing
LoopRunResponse, so its node_controls and waits JSON fields serialize as []
rather than null when the store has no entries. Preserve existing populated
values and apply this in the response-building flow.
internal/store/globaldb/global_db_loop_wait_escalation_apply.go-214-224 (1)

214-224: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the single rendered intent before indexing.

RenderEffectIntens renders each spec into an intent; an invalid node-scoped identity or empty JSON entry returns an error, but a malformed rendered entry can still fail validation silently if the contract changes. Use a length check before intents[0].EntryIndex = cursor to avoid a panic.

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

In `@internal/store/globaldb/global_db_loop_wait_escalation_apply.go` around lines
214 - 224, Before indexing the result from RenderEffectIntents in the
wait-escalation flow, validate that intents contains at least one rendered
intent and return an appropriate error when it is empty. Preserve the existing
error return from RenderEffectIntents and only assign EntryIndex after the
length check.
internal/daemon/loop_hook_observer.go-335-352 (1)

335-352: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Map failed terminal runs to an explicit disposition.

TaskRunStatusFailed is a valid task status. When a failed terminal run reaches OnTaskRunTerminal, FailureClass can be populated, but loopNodeHookDisposition returns "" because only completed and canceled statuses are mapped. Map failed terminal status to an explicit disposition, or avoid setting FailureClass unless Disposition is also populated.

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

In `@internal/daemon/loop_hook_observer.go` around lines 335 - 352, Update
loopNodeHookDisposition to map TaskRunStatusFailed to an explicit failure
disposition consistent with the existing looppkg attempt constants. Ensure
failed terminal runs reaching OnTaskRunTerminal do not produce a populated
FailureClass alongside an empty Disposition, while preserving the completed and
canceled mappings.
internal/store/globaldb/global_db_loop_death_resume.go-60-62 (1)

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

The AttentionRequired signal has no end-to-end path. The store never sets AttentionRequired on a committed continuation, and the daemon never reads the field, so a node that reaches the resume limit produces no operator-visible signal at the moment it happens.

  • internal/store/globaldb/global_db_loop_death_resume.go#L60-L62: set AttentionRequired: prepared.streak >= request.DeathStreakLimit on the successful continuation result, so the flag matches the flagResumeExhausted call at lines 274-277.
  • internal/daemon/loop_action_death_resume.go#L50-L64: read result.AttentionRequired and record it in the existing log entry, so the exhausted resume streak is observable.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/globaldb/global_db_loop_death_resume.go` around lines 60 - 62,
The committed continuation result in
internal/store/globaldb/global_db_loop_death_resume.go lines 60-62 must set
AttentionRequired when prepared.streak reaches request.DeathStreakLimit,
matching flagResumeExhausted. In internal/daemon/loop_action_death_resume.go
lines 50-64, read result.AttentionRequired and include it in the existing log
entry so exhausted resume streaks are operator-visible.
internal/cli/loop.go-64-69 (1)

64-69: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Describe the compozy loop stop replacement in release notes.

The CLI and web run page already expose cancel/kill and no longer render stop, but the release notes do not describe the removed compozy loop stop action. Existing callers may otherwise break without notice.

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

In `@internal/cli/loop.go` around lines 64 - 69, Update the release notes to
document that the deprecated compozy loop stop action was removed and replaced
by the existing cancel/kill actions exposed by the CLI and web run page.
Reference the loop command behavior clearly so existing callers understand the
migration.
internal/store/globaldb/global_db_loop_wait_resume.go-203-216 (1)

203-216: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Classify the missing wait cell as a conflict.

If the generation output row is absent, Scan returns sql.ErrNoRows and this code wraps it as a generic store error. Callers cannot distinguish it from an infrastructure failure, so the API maps it to a server error instead of a conflict. Map sql.ErrNoRows to looppkg.ErrTransitionConflict, as loadWaitForResume already does for a missing wait.

🐛 Proposed fix
 		mutation.ItemIndex).Scan(&status, &epoch, &paused, &quarantined, &cancelState)
+	if errors.Is(err, sql.ErrNoRows) {
+		return fmt.Errorf("%w: wait cell is missing", looppkg.ErrTransitionConflict)
+	}
 	if err != nil {
 		return fmt.Errorf("store: load Loop wait cell: %w", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/globaldb/global_db_loop_wait_resume.go` around lines 203 -
216, Update the query error handling in the wait-cell loading flow to map
sql.ErrNoRows to looppkg.ErrTransitionConflict, matching the existing behavior
in loadWaitForResume. Preserve the current wrapped store error for all other
query failures and keep the subsequent status validation unchanged.
internal/store/globaldb/global_db_loop_cancel_wake.go-40-52 (1)

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

Make the scope explicit in the idempotency key.

cancellationWakeIdempotencyKey writes the literal "run" for run scope and the raw node ID for node scope into the same key segment. A node whose ID is run then produces the same key as run-scope cancellation for the same run, kind, and timestamp. Prefix the segment with its scope so run keys and node keys can never collide.

🐛 Proposed fix
 func cancellationWakeIdempotencyKey(mutation looppkg.CancellationMutation) string {
-	target := cancellationTargetRun
+	target := "run:" + cancellationTargetRun
 	if mutation.NodeID != "" {
-		target = string(mutation.NodeID)
+		target = "node:" + string(mutation.NodeID)
 	}
 	return fmt.Sprintf(
 		"loop.cancel.%s.%s.%s.%d",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/globaldb/global_db_loop_cancel_wake.go` around lines 40 - 52,
Update cancellationWakeIdempotencyKey so the target segment encodes its scope
explicitly: use a run-scoped prefix for cancellationTargetRun and a distinct
node-scoped prefix before the node ID. Preserve the existing run ID, mutation
kind, and timestamp components while ensuring a node ID of "run" cannot collide
with a run-scope key.
internal/store/globaldb/global_db_loop_wait_expiry_route.go-66-84 (1)

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

Emit the same event payload shape for timer and approval expiry.

The approval path adds eventSummaryContentPayloadKey: json.RawMessage(payload) (line 125). This timer path omits it, although payload holds the same expired and route content and is already persisted to ahead_payload_json. Consumers of loopRunEventNodeWaitResumed then see two different payload shapes for one event kind. Add the summary content key here, or drop it from both paths.

The two blocks also repeat the identical payload map and the identical coordinator idempotency key format. Extract one helper for the event payload and one for the reservation key.

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

In `@internal/store/globaldb/global_db_loop_wait_expiry_route.go` around lines 66
- 84, Unify timer and approval expiry handling so loopRunEventNodeWaitResumed
emits the same payload shape, including eventSummaryContentPayloadKey with the
expired/route payload, in both paths. Extract shared helpers for constructing
the event payload and coordinator reservation idempotency key, then use them
from this expiry flow and the approval flow while preserving existing values and
behavior.
sdk/go/watch_source.go-152-171 (1)

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

Make the new event_key contract explicit in docs.

The Go and TypeScript SDKs enforce event_key as valid UTF-8 NFC, trimmed, non-empty, and ≤256 bytes, but extension documentation or a migration note does not communicate this new required field. Add SDK/user-facing documentation for this breaking WatchPollResponse change.

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

In `@sdk/go/watch_source.go` around lines 152 - 171, The WatchPollResponse
contract needs user-facing documentation for the required event_key field.
Update the Go and TypeScript SDK or extension documentation, and add a migration
note if that is the established location, describing that event_key must be
valid UTF-8 NFC, trimmed, non-empty, and no longer than 256 bytes.
internal/daemon/native_loop_tools_support.go-241-242 (1)

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

Map ReasonCodeAlreadyDecided to ErrorCodeConflict.

ReasonCodeAlreadyDecided wraps ErrTransitionConflict, and StatusForLoopError classifies ErrTransitionConflict as 409 Conflict. This ReasonError path returns before the HTTP status switch, so it still emits ErrorCodeInvalidInput; use toolspkg.ErrorCodeConflict for already-decided lifecycle compare-and-swap rejections to keep tool errors consistent with state-conflict rejections.

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

In `@internal/daemon/native_loop_tools_support.go` around lines 241 - 242, Update
the ReasonCodeAlreadyDecided branch in the reason mapping to assign
toolspkg.ErrorCodeConflict instead of toolspkg.ReasonLoopAlreadyDecided,
preserving conflict semantics for lifecycle compare-and-swap rejections.
internal/loop/coordinator_test.go-749-751 (1)

749-751: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

This parent-mutation assertion cannot fail.

parent is a Run value. refreshAwaitingChildOutput receives a copy, so it can never change the local parent. The check always passes and does not protect the stated invariant. Assert against the persisted parent run instead, for example by reading the run back from the runner store fixture after the call.

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

In `@internal/loop/coordinator_test.go` around lines 749 - 751, Replace the
ineffective local parent assertion in the refreshAwaitingChildOutput test with
an assertion against the persisted parent run, rereading it from the runner
store fixture after the call. Verify the stored run still has StatusRunning and
CancelRequested unset, while preserving the existing child-terminal scenario.
internal/loop/service.go-344-365 (1)

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

Gate StatusCanceled for live states outside cancellation paths.

allowedTransition allows StatusRunning and StatusWatching to become StatusCanceled with any cause, while StatusQueued, StatusNeedsApproval, and StatusPaused require TransitionCauseOperatorCancel or TransitionCauseOperatorKill. If non-operator causes must reach canceled from these live states, add the specific cause to cancelTransition or use another terminal status for that path.

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

In `@internal/loop/service.go` around lines 344 - 365, The allowedTransition cases
for StatusRunning and StatusWatching currently permit StatusCanceled regardless
of cause. Align these live-state transitions with the cause-gated behavior used
by StatusNeedsApproval and StatusPaused: remove unconditional StatusCanceled
acceptance and route it through cancelTransition, or explicitly allow only the
required operator cancellation causes while preserving other transitions.
internal/store/globaldb/global_db_loop_node_inventory.go-142-167 (1)

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

Drop item_index from quarantined and attention cursors.

ListQuarantinedLoopNodeInventory and ListAttentionLoopNodeInventory return 0 AS item_index and order/keyset only on state_at/at, run.id, generation, and node_id, while waiting/retrying pages include item_index in both the query and cursor. Since these queries do not use item_index for ordering or pagination, do not pass CursorItemIndex for NodeInventoryQuarantined and NodeInventoryAttention so the cursor contract matches each query.

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

In `@internal/store/globaldb/global_db_loop_node_inventory.go` around lines 142 -
167, Update the NodeInventoryQuarantined and NodeInventoryAttention branches to
omit CursorItemIndex from their query parameter structs, matching the queries’
ordering and pagination keys. Leave the waiting/retrying cursor handling
unchanged.
internal/store/globaldb/global_db_loop_node_pause_effects.go-52-52 (1)

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

Normalize mutation.RequestedAt to UTC before persistence.

The sibling cancellation path in internal/store/globaldb/global_db_loop_cancel_finalize.go passes mutation.RequestedAt.UTC() into insertLoopEffectIntentsWithExecutor. This call passes the raw value. If a caller supplies a non-UTC time, the stored effect intent timestamps use a different zone from the surrounding rows, which affects comparisons and ordering.

🐛 Proposed fix
-	return insertLoopEffectIntentsWithExecutor(ctx, exec, run, sourceID, intents, mutation.RequestedAt)
+	return insertLoopEffectIntentsWithExecutor(ctx, exec, run, sourceID, intents, mutation.RequestedAt.UTC())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/globaldb/global_db_loop_node_pause_effects.go` at line 52,
Update the pause-effect persistence call in
insertLoopEffectIntentsWithExecutor’s caller to pass mutation.RequestedAt
normalized with UTC, matching the sibling cancellation path and ensuring stored
intent timestamps use UTC.
internal/loop/linter_references.go-151-158 (1)

151-158: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include wait event filters in unknown-output reference linting.

Control-wait event subscriptions can live under params.event, but lintWaitReferences() only passes params.until through compileTemplate() and lintWatchEventsFilterReferences() reads node.Events, which is empty for control-wait nodes. Add a dedicated check from params.event.Filter, or include the remaining wait param fields so template unknown-output references in the filter are still reported.

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

In `@internal/loop/linter_references.go` around lines 151 - 158, Update
nodeParamStringFields and the lintWaitReferences flow to decode control-wait
dsl.WaitParams and inspect params.event.Filter in addition to params.until, so
template references in wait event filters are passed through the existing
unknown-output linting path even when node.Events is empty. Reuse the existing
compileTemplate/linting helpers and preserve the current behavior for empty or
invalid wait parameters.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 36afcc2d-861a-4ddf-95df-1d88302f091a

📥 Commits

Reviewing files that changed from the base of the PR and between 845a795 and 8b9b224.

⛔ Files ignored due to path filters (50)
  • docs/qa/bugs/BUG-20260802-initial-wait-fails-run.md is excluded by !**/*.md
  • docs/qa/bugs/BUG-20260802-node-kill-leaves-run-live.md is excluded by !**/*.md
  • docs/qa/bugs/BUG-20260802-parked-node-cancel-stalls.md is excluded by !**/*.md
  • docs/qa/charters/CH-003.md is excluded by !**/*.md
  • docs/qa/charters/CH-agent-loop-lifecycle-native.md is excluded by !**/*.md
  • docs/qa/charters/CH-loop-death-resume.md is excluded by !**/*.md
  • docs/qa/charters/CH-loop-run-cancel-control.md is excluded by !**/*.md
  • docs/qa/charters/CH-runaway-work-bounded.md is excluded by !**/*.md
  • docs/qa/journeys/J-04-operator-pause-resume.md is excluded by !**/*.md
  • docs/qa/journeys/J-07-agent-operated-run.md is excluded by !**/*.md
  • docs/qa/journeys/J-bound-runaway-work.md is excluded by !**/*.md
  • docs/qa/journeys/J-resume-dead-loop-node.md is excluded by !**/*.md
  • docs/qa/reports/2026-08-02-loop-node-lifecycle-task07.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-016.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-agent-operates-lifecycle-via-native-tools.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-approval-link-journey.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-cancel-vs-kill.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-crash-death-resume.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-days-long-node-no-clock.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-duplicate-event-suppressed.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-durable-wait-restart.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-live-pause-repair-resume.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-waiting-inventory-escalation.md is excluded by !**/*.md
  • docs/qa/scenarios/TA-076.md is excluded by !**/*.md
  • docs/qa/scenarios/TA-084.md is excluded by !**/*.md
  • docs/qa/scenarios/TA-action-run-liveness.md is excluded by !**/*.md
  • internal/store/globaldb/schema/migrations/atlas.sum is excluded by !**/*.sum, !**/*.sum
  • internal/tools/builtin/testdata/native-tool-catalog.json is excluded by !**/*.json
  • openapi/compozy.json is excluded by !**/*.json
  • packages/site/content/docs/cli/loop/cancel.mdx is excluded by !**/*.mdx
  • packages/site/content/docs/cli/loop/index.mdx is excluded by !**/*.mdx
  • packages/site/content/docs/cli/loop/kill.mdx is excluded by !**/*.mdx
  • packages/site/content/docs/cli/loop/meta.json is excluded by !**/*.json
  • packages/site/content/docs/cli/loop/node/cancel.mdx is excluded by !**/*.mdx
  • packages/site/content/docs/cli/loop/node/index.mdx is excluded by !**/*.mdx
  • packages/site/content/docs/cli/loop/node/kill.mdx is excluded by !**/*.mdx
  • packages/site/content/docs/cli/loop/node/meta.json is excluded by !**/*.json
  • packages/site/content/docs/cli/loop/node/pause.mdx is excluded by !**/*.mdx
  • packages/site/content/docs/cli/loop/node/requeue.mdx is excluded by !**/*.mdx
  • packages/site/content/docs/cli/loop/node/resume.mdx is excluded by !**/*.mdx
  • packages/site/content/docs/cli/loop/nodes.mdx is excluded by !**/*.mdx
  • packages/site/content/docs/loops/running.mdx is excluded by !**/*.mdx
  • sdk/typescript/src/generated/contracts.ts is excluded by !**/generated/**, !**/generated/**
  • skills/compozy/references/extension-authoring.md is excluded by !**/*.md
  • skills/compozy/references/loops.md is excluded by !**/*.md
  • skills/compozy/references/tasks-and-orchestration.md is excluded by !**/*.md
  • web/src/generated/compozy-openapi.d.ts is excluded by !**/generated/**, !**/generated/**, !**/*.d.ts
  • web/src/generated/loop-enums.ts is excluded by !**/generated/**, !**/generated/**
  • web/src/systems/loops/components/stories/loop-run-page.stories.tsx is excluded by !**/*.stories.tsx
  • web/src/systems/loops/components/stories/loop-status-pill.stories.tsx is excluded by !**/*.stories.tsx
📒 Files selected for processing (250)
  • internal/api/contract/generic_error_payload.go
  • internal/api/contract/loop_enums.go
  • internal/api/contract/loop_nodes.go
  • internal/api/contract/loop_runs.go
  • internal/api/contract/loops.go
  • internal/api/core/errors_diagnostic_payload.go
  • internal/api/core/errors_test.go
  • internal/api/core/loop_interfaces.go
  • internal/api/core/loops.go
  • internal/api/core/loops_nodes.go
  • internal/api/core/loops_test.go
  • internal/api/httpapi/handlers_test.go
  • internal/api/httpapi/loops_routes.go
  • internal/api/spec/loops.go
  • internal/api/spec/loops_nodes.go
  • internal/api/spec/loops_test.go
  • internal/api/spec/schema_enum_registry.go
  • internal/api/udsapi/handlers_test.go
  • internal/api/udsapi/loops_routes.go
  • internal/automation/dispatch_loop.go
  • internal/cli/client_loops.go
  • internal/cli/helpers_test.go
  • internal/cli/loop.go
  • internal/cli/loop_client.go
  • internal/cli/loop_inspect_output.go
  • internal/cli/loop_node.go
  • internal/cli/loop_nodes.go
  • internal/cli/loop_runs.go
  • internal/cli/loop_support.go
  • internal/cli/loop_test.go
  • internal/cli/marketplace.go
  • internal/config/merge_task.go
  • internal/config/task_orchestration.go
  • internal/config/task_orchestration_test.go
  • internal/config/task_orchestration_validation.go
  • internal/config/tool_surface_task.go
  • internal/config/tool_surface_test.go
  • internal/daemon/automation_loop_starter.go
  • internal/daemon/loop_action_checkpoint.go
  • internal/daemon/loop_action_death_resume.go
  • internal/daemon/loop_action_liveness.go
  • internal/daemon/loop_action_liveness_integration_helpers_test.go
  • internal/daemon/loop_action_runtime.go
  • internal/daemon/loop_api_contract.go
  • internal/daemon/loop_api_nodes.go
  • internal/daemon/loop_api_payloads_test.go
  • internal/daemon/loop_api_run_history.go
  • internal/daemon/loop_api_runs.go
  • internal/daemon/loop_api_runs_test.go
  • internal/daemon/loop_api_service.go
  • internal/daemon/loop_goal_managed_runtime_integration_test.go
  • internal/daemon/loop_hook_observer.go
  • internal/daemon/loop_hook_observer_test.go
  • internal/daemon/loop_managed_binding.go
  • internal/daemon/loop_parent_close_postcommit.go
  • internal/daemon/loop_watch_events_doorbell.go
  • internal/daemon/loop_watch_events_observer.go
  • internal/daemon/loop_watch_events_observer_index.go
  • internal/daemon/loop_watch_events_observer_test.go
  • internal/daemon/loop_watch_events_payloads.go
  • internal/daemon/native_hook_helpers.go
  • internal/daemon/native_loop_lifecycle_support.go
  • internal/daemon/native_loop_lifecycle_tools.go
  • internal/daemon/native_loop_tools.go
  • internal/daemon/native_loop_tools_support.go
  • internal/daemon/native_loop_tools_test.go
  • internal/daemon/scheduler_loop_coordinator.go
  • internal/daemon/scheduler_loop_due_scan.go
  • internal/daemon/scheduler_runtime.go
  • internal/daemon/scheduler_task_source_types.go
  • internal/daemon/task_runtime_boot.go
  • internal/daemon/task_runtime_test.go
  • internal/extension/scaffold_templates/loop-watch-source-go/main.go
  • internal/extension/watch_source.go
  • internal/extension/watch_source_test.go
  • internal/hooks/payloads_task_loop.go
  • internal/loop/action_failure.go
  • internal/loop/action_retry_feedback.go
  • internal/loop/action_runagent.go
  • internal/loop/action_schema.go
  • internal/loop/action_test.go
  • internal/loop/action_types.go
  • internal/loop/action_usage.go
  • internal/loop/admission_claim.go
  • internal/loop/cancel_control.go
  • internal/loop/compiler.go
  • internal/loop/control_eval_context.go
  • internal/loop/control_plan.go
  • internal/loop/control_plan_branch.go
  • internal/loop/control_plan_finish.go
  • internal/loop/control_topology.go
  • internal/loop/coordinator.go
  • internal/loop/coordinator_action.go
  • internal/loop/coordinator_action_liveness.go
  • internal/loop/coordinator_action_metadata.go
  • internal/loop/coordinator_autopause.go
  • internal/loop/coordinator_cancellation.go
  • internal/loop/coordinator_control_test.go
  • internal/loop/coordinator_effects.go
  • internal/loop/coordinator_error_routes.go
  • internal/loop/coordinator_gate_wait.go
  • internal/loop/coordinator_generation.go
  • internal/loop/coordinator_lifecycle.go
  • internal/loop/coordinator_lifecycle_test.go
  • internal/loop/coordinator_metadata.go
  • internal/loop/coordinator_outputs.go
  • internal/loop/coordinator_pause.go
  • internal/loop/coordinator_quarantine.go
  • internal/loop/coordinator_test.go
  • internal/loop/coordinator_wait.go
  • internal/loop/coordinator_wait_route.go
  • internal/loop/coordinator_watch_test.go
  • internal/loop/death_resume.go
  • internal/loop/dsl/graph.go
  • internal/loop/effects_dispatch.go
  • internal/loop/effects_dispatch_test.go
  • internal/loop/errors.go
  • internal/loop/executed_definition_validation.go
  • internal/loop/executed_definition_watch.go
  • internal/loop/generation_snapshot.go
  • internal/loop/generation_snapshot_controls.go
  • internal/loop/generation_snapshot_intents.go
  • internal/loop/goal_cancellation.go
  • internal/loop/linter.go
  • internal/loop/linter_references.go
  • internal/loop/linter_test.go
  • internal/loop/linter_wait.go
  • internal/loop/node_control_mutation.go
  • internal/loop/node_inventory.go
  • internal/loop/node_liveness.go
  • internal/loop/node_pause.go
  • internal/loop/node_wait.go
  • internal/loop/node_waits.go
  • internal/loop/parent_close.go
  • internal/loop/run_network_state.go
  • internal/loop/service.go
  • internal/loop/service_cancellation.go
  • internal/loop/service_control.go
  • internal/loop/service_node_pause.go
  • internal/loop/service_node_requeue.go
  • internal/loop/service_options.go
  • internal/loop/service_start.go
  • internal/loop/service_test.go
  • internal/loop/service_types.go
  • internal/loop/wait_due.go
  • internal/loop/watch/adapter.go
  • internal/loop/watch/adapter_test.go
  • internal/loop/watch/event_key.go
  • internal/loop/watch/output.go
  • internal/loop/watch/types.go
  • internal/loop/watch_events_constraints.go
  • internal/loop/watch_events_contract.go
  • internal/loop/watch_events_ledger.go
  • internal/session/manager_prompt_submit.go
  • internal/session/manager_test.go
  • internal/session/session_supervision.go
  • internal/store/globaldb/global_db_goal_reentry.go
  • internal/store/globaldb/global_db_goal_reentry_helpers.go
  • internal/store/globaldb/global_db_goal_reentry_test.go
  • internal/store/globaldb/global_db_goal_stop.go
  • internal/store/globaldb/global_db_goal_stop_checkpoints.go
  • internal/store/globaldb/global_db_loop.go
  • internal/store/globaldb/global_db_loop_admission_claims.go
  • internal/store/globaldb/global_db_loop_approval_wait.go
  • internal/store/globaldb/global_db_loop_cancel.go
  • internal/store/globaldb/global_db_loop_cancel_finalize.go
  • internal/store/globaldb/global_db_loop_cancel_live.go
  • internal/store/globaldb/global_db_loop_cancel_sessions.go
  • internal/store/globaldb/global_db_loop_cancel_wake.go
  • internal/store/globaldb/global_db_loop_control.go
  • internal/store/globaldb/global_db_loop_coordinator_seed.go
  • internal/store/globaldb/global_db_loop_death_resume.go
  • internal/store/globaldb/global_db_loop_effect_outbox.go
  • internal/store/globaldb/global_db_loop_events.go
  • internal/store/globaldb/global_db_loop_inline.go
  • internal/store/globaldb/global_db_loop_lifecycle_errors.go
  • internal/store/globaldb/global_db_loop_node_inventory.go
  • internal/store/globaldb/global_db_loop_node_inventory_details.go
  • internal/store/globaldb/global_db_loop_node_liveness.go
  • internal/store/globaldb/global_db_loop_node_pause.go
  • internal/store/globaldb/global_db_loop_node_pause_effects.go
  • internal/store/globaldb/global_db_loop_node_requeue.go
  • internal/store/globaldb/global_db_loop_node_terminal.go
  • internal/store/globaldb/global_db_loop_normalize.go
  • internal/store/globaldb/global_db_loop_parked_clock.go
  • internal/store/globaldb/global_db_loop_pinning.go
  • internal/store/globaldb/global_db_loop_schema_integration_test.go
  • internal/store/globaldb/global_db_loop_test.go
  • internal/store/globaldb/global_db_loop_transition.go
  • internal/store/globaldb/global_db_loop_wait_due.go
  • internal/store/globaldb/global_db_loop_wait_escalation.go
  • internal/store/globaldb/global_db_loop_wait_escalation_apply.go
  • internal/store/globaldb/global_db_loop_wait_events.go
  • internal/store/globaldb/global_db_loop_wait_expiry_route.go
  • internal/store/globaldb/global_db_loop_wait_resume.go
  • internal/store/globaldb/global_db_network_work_mutation.go
  • internal/store/globaldb/global_db_task_claim.go
  • internal/store/globaldb/global_db_task_claim_helpers.go
  • internal/store/globaldb/global_db_task_claim_test.go
  • internal/store/globaldb/global_db_task_coordinator.go
  • internal/store/globaldb/global_db_task_coordinator_budget.go
  • internal/store/globaldb/global_db_task_coordinator_cancellation.go
  • internal/store/globaldb/global_db_task_coordinator_effects.go
  • internal/store/globaldb/global_db_task_coordinator_intents.go
  • internal/store/globaldb/global_db_task_coordinator_mutations.go
  • internal/store/globaldb/global_db_task_coordinator_parent_close.go
  • internal/store/globaldb/global_db_task_coordinator_runtime.go
  • internal/store/globaldb/global_db_watch_events_loop.go
  • internal/store/globaldb/queries/loop_lifecycle.sql
  • internal/store/globaldb/schema/definitions/50_loops.sql
  • internal/store/globaldb/schema/migrations/00045_schema.sql
  • internal/store/globaldb/sqlcgen/loop_lifecycle.sql.go
  • internal/store/globaldb/sqlcgen/models.go
  • internal/task/coordinator.go
  • internal/task/coordinator_boundary_specs.go
  • internal/task/manager.go
  • internal/task/manager_constructor.go
  • internal/task/manager_coordinator_run.go
  • internal/task/manager_test.go
  • internal/toolmeta/native_entries.go
  • internal/toolmeta/registry_test.go
  • internal/tools/builtin/builtin_test.go
  • internal/tools/builtin/descriptors.go
  • internal/tools/builtin/loops.go
  • internal/tools/builtin/loops_lifecycle_schemas.go
  • internal/tools/builtin/loops_output_schemas.go
  • internal/tools/builtin/tasks.go
  • internal/tools/builtin_ids.go
  • internal/tools/reason.go
  • sdk/go/contracts/types_011_gen.go
  • sdk/go/extension_test.go
  • sdk/go/types.go
  • sdk/go/watch_source.go
  • sdk/typescript/src/__tests__/extension.test.ts
  • sdk/typescript/src/watch-source.ts
  • web/src/systems/loops/adapters/__tests__/loops-api.test.ts
  • web/src/systems/loops/adapters/loops-api.ts
  • web/src/systems/loops/adapters/loops-runs-api.ts
  • web/src/systems/loops/components/__tests__/loop-run-page.test.tsx
  • web/src/systems/loops/components/run-page/loop-run-controls.tsx
  • web/src/systems/loops/hooks/use-loop-actions.ts
  • web/src/systems/loops/index.ts
  • web/src/systems/loops/lib/__tests__/loop-formatters.test.ts
  • web/src/systems/loops/lib/loop-formatters.ts
  • web/src/systems/loops/lib/loop-generation-presentation.ts
  • web/src/systems/loops/mocks/fixture-run-details.ts
  • web/src/systems/loops/mocks/handlers.ts
  • web/src/systems/loops/types.ts
  • web/src/systems/os/apps/loops/loop-run-detail-location.tsx
  • web/src/systems/os/apps/loops/use-loop-run-page.ts
💤 Files with no reviewable changes (9)
  • internal/config/task_orchestration.go
  • internal/config/merge_task.go
  • internal/api/core/loops.go
  • internal/config/tool_surface_test.go
  • internal/config/tool_surface_task.go
  • internal/config/task_orchestration_validation.go
  • internal/loop/service_control.go
  • internal/config/task_orchestration_test.go
  • internal/store/globaldb/global_db_goal_stop.go

Comment on lines +134 to +150
func (h *BaseHandlers) ListLoopNodes(c *gin.Context) {
service, ok := h.requireLoopService(c)
if !ok {
return
}
query, err := ParseLoopNodeListQuery(c)
if err != nil {
h.respondLoopError(c, fmt.Errorf("%w: loop node inventory query: %v", looppkg.ErrValidation, err))
return
}
response, err := service.ListLoopNodes(c.Request.Context(), c.Param("workspace_id"), query)
if err != nil {
h.respondLoopError(c, err)
return
}
c.JSON(http.StatusOK, response)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare workspace scoping between loop read handlers and the new inventory handler.
set -euo pipefail

fd -t f 'loops.*\.go' internal/api | xargs rg -n -C4 'workspace_id"\)' | rg -n -C4 'ListLoop|GetLoopRun|ListLoopNodes'
rg -n -C6 'func \(h \*BaseHandlers\) (ListLoopRuns|GetLoopRun)\(' internal/api/core
rg -n -C4 'taskActorContextForWorkspace|requireWorkspaceScope|authorizeWorkspace' internal/api/core internal/api/httpapi internal/api/udsapi

Repository: compozy/compozy

Length of output: 20037


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate workspace-scope definitions/handlers and compare read endpoints.
rg -n -C8 'taskActorContextForWorkspace|requireWorkspaceScope|authorizeWorkspace|workspace_scope|WorkspaceID' internal/api/core internal/api/httpapi internal/api/udsapi internal/api

printf '\n--- loops.go read handlers ---\n'
sed -n '220,320p' internal/api/core/loops.go

printf '\n--- loops_nodes.go handlers ---\n'
sed -n '1,170p' internal/api/core/loops_nodes.go

Repository: compozy/compozy

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- focused workspace scope definitions ---'
rg -n -C6 'func \(h \*BaseHandlers\) taskActorContextForWorkspace|func \(h \*BaseHandlers\) requireWorkspaceScope|func .*authorizeWorkspace|WorkspaceID\(\)' internal/api/core/tasks.go internal/api/core/tasks_internal_test.go internal/api/core/router.go internal/api/core/workspaces.go | sed -n '1,240p'

printf '%s\n' '--- BaseHandlers auth session workspace extraction context ---'
rg -n -C4 'Pending\.WorkspaceID|caller.*Workspace|Session.*WorkspaceID|WorkspaceID\(\)|context\.Value\(.*Workspace|caller\(\)' internal/api/core/tasks.go internal/api/core/transport auth internal || true

printf '%s\n' '--- relevant read handlers context ---'
sed -n '220,320p' internal/api/core/loops.go
sed -n '1,170p' internal/api/core/loops_nodes.go

Repository: compozy/compozy

Length of output: 1072


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- taskActorContextForWorkspace implementation ---'
sed -n '100,210p' internal/api/core/tasks.go

printf '%s\n' '--- BaseHandlers config fields ---'
sed -n '1,100p' internal/api/core/base.go

printf '%s\n' '--- workspace scope extraction references ---'
rg -n -C4 'TaskActorContextResolver|Pending|WorkspaceID\(\)|Caller|caller\(' internal/api/core --glob '*.go' | sed -n '1,260p'

Repository: compozy/compozy

Length of output: 1677


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- locate BaseHandlers config/constructor references ---'
rg -n -C4 'type BaseHandlers|type BaseHandlerConfig|NewBaseHandlers|TaskActorContextResolver' internal/api --glob '*.go' | sed -n '1,260p'

printf '%s\n' '--- relevant BaseHandlers/workspace resolver snippet ---'
rg -n -C3 'resolveAgentCallerForWorkspace|HasAccess|Workspace.*Access|Pending\.Workspace|Session\.WorkspaceID|WorkspaceID' internal/api/core internal/api/httpapi internal/api/udsapi --glob '*.go' | sed -n '1,260p'

Repository: compozy/compozy

Length of output: 37254


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- coreHandlerConfig around resolver ---'
rg -n -C12 'coreHandlerConfig|TaskActorContextResolver.*func|func resolver|func.*resolver|AgentCaller|resolveSessionForWorkspace|Pending' internal/api/httpapi --glob '*.go' | sed -n '1,260p'

printf '%s\n' '--- udsCoreHandlerConfig around resolver ---'
rg -n -C12 'udsCoreHandlerConfig|TaskActorContextResolver.*func|func resolver|func.*resolver|AgentCaller|resolveSessionForWorkspace|Pending' internal/api/udsapi --glob '*.go' | sed -n '1,240p'

printf '%s\n' '--- full snippets if available ---'
fd -t f -i 'handlers.go|core_handlers.go' internal/api/httpapi internal/api/udsapi internal/api/core | while read -r f; do
  echo "### $f"
  rg -n -C8 'TaskActorContextResolver|resolveSessionForWorkspace|resolve.*Workspace|AgentCaller|agentCallerCredentialsFromRequest|WorkspaceID\(\)' "$f" | sed -n '1,180p'
done

Repository: compozy/compozy

Length of output: 26012


Match the other loop read endpoints.

ListLoopNodes uses c.Param("workspace_id") directly like ListLoopRuns and GetLoopRun, while loop mutations enforce scope with taskActorContextForWorkspace. Return inventory only in the caller’s permitted workspace before using the path parameter or add a read-scoped workspace check.

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

In `@internal/api/core/loops_nodes.go` around lines 134 - 150, Update
ListLoopNodes to enforce the caller’s workspace scope before invoking
service.ListLoopNodes, matching the authorization behavior of loop mutations and
other loop read endpoints. Reuse taskActorContextForWorkspace or the established
read-scoped workspace check, and only use c.Param("workspace_id") after
permission validation.

Comment on lines +165 to +169
func automationLoopAdmission(req automationpkg.LoopStartRequest) *looppkg.AdmissionIdentity {
if strings.TrimSpace(req.SourceKey) == "" || strings.TrimSpace(req.EventKey) == "" {
return nil
}
return &looppkg.AdmissionIdentity{SourceKey: req.SourceKey, EventKey: req.EventKey}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Trim the keys that you store in AdmissionIdentity.

The guard trims req.SourceKey and req.EventKey, but the returned identity carries the untrimmed values. The admission identity is a deduplication key, so leading or trailing whitespace produces a different key for the same logical event. The dispatcher path currently trims event_key, but no other caller is required to.

🐛 Proposed fix
 func automationLoopAdmission(req automationpkg.LoopStartRequest) *looppkg.AdmissionIdentity {
-	if strings.TrimSpace(req.SourceKey) == "" || strings.TrimSpace(req.EventKey) == "" {
+	sourceKey := strings.TrimSpace(req.SourceKey)
+	eventKey := strings.TrimSpace(req.EventKey)
+	if sourceKey == "" || eventKey == "" {
 		return nil
 	}
-	return &looppkg.AdmissionIdentity{SourceKey: req.SourceKey, EventKey: req.EventKey}
+	return &looppkg.AdmissionIdentity{SourceKey: sourceKey, EventKey: eventKey}
 }
📝 Committable suggestion

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

Suggested change
func automationLoopAdmission(req automationpkg.LoopStartRequest) *looppkg.AdmissionIdentity {
if strings.TrimSpace(req.SourceKey) == "" || strings.TrimSpace(req.EventKey) == "" {
return nil
}
return &looppkg.AdmissionIdentity{SourceKey: req.SourceKey, EventKey: req.EventKey}
func automationLoopAdmission(req automationpkg.LoopStartRequest) *looppkg.AdmissionIdentity {
sourceKey := strings.TrimSpace(req.SourceKey)
eventKey := strings.TrimSpace(req.EventKey)
if sourceKey == "" || eventKey == "" {
return nil
}
return &looppkg.AdmissionIdentity{SourceKey: sourceKey, EventKey: eventKey}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/daemon/automation_loop_starter.go` around lines 165 - 169, Update
automationLoopAdmission to store trimmed SourceKey and EventKey in
looppkg.AdmissionIdentity, reusing the validated trimmed values for both the
blank checks and returned deduplication key so leading or trailing whitespace
cannot create distinct identities.

Comment on lines +40 to +48
for _, event := range events {
partial, found, err := deathResumePartialFromEvent(event)
if err != nil {
return nil, err
}
if found {
checkpoint.Partials = append(checkpoint.Partials, partial)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

One malformed event blocks the whole recovery.

deathResumePartialFromEvent returns an error when transcript.UnmarshalAgentEvent fails, and this loop propagates it. The caller then abandons the confirmed-death continuation. A checkpoint is best-effort context for the prompt, so a single undecodable event should not stop recovery. Skip the event and log it instead.

♻️ Proposed direction
 	for _, event := range events {
 		partial, found, err := deathResumePartialFromEvent(event)
 		if err != nil {
-			return nil, err
+			// Best-effort context: a single undecodable event must not block recovery.
+			continue
 		}
 		if found {
 			checkpoint.Partials = append(checkpoint.Partials, partial)
 		}
 	}

Pass a logger into the helper if you want the skipped sequence recorded.

📝 Committable suggestion

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

Suggested change
for _, event := range events {
partial, found, err := deathResumePartialFromEvent(event)
if err != nil {
return nil, err
}
if found {
checkpoint.Partials = append(checkpoint.Partials, partial)
}
}
for _, event := range events {
partial, found, err := deathResumePartialFromEvent(event)
if err != nil {
// Best-effort context: a single undecodable event must not block recovery.
continue
}
if found {
checkpoint.Partials = append(checkpoint.Partials, partial)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/daemon/loop_action_checkpoint.go` around lines 40 - 48, Update the
event-processing loop around deathResumePartialFromEvent so an unmarshalling
error is logged and the malformed event is skipped rather than returned to the
caller. Preserve appending successfully decoded partials and allow recovery to
continue processing subsequent events; pass a logger into the helper or use the
surrounding logger so the skipped event’s sequence is recorded.

Comment on lines +25 to +28
info, err := r.sessions.Status(ctx, strings.TrimSpace(sessionID))
if err != nil || !confirmedLoopActionSessionDeath(info) {
return false, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not swallow the Status error silently.

Line 26 collapses a store failure and a live session into the same result. A transient Status error is then reported as "not dead", and the confirmed-death path is skipped with no record. Separate the two cases: log or return the error, and keep the fail-open behavior only as a documented decision.

The coding guidelines require that every error is handled or carries a written justification.

🐛 Proposed fix
 	info, err := r.sessions.Status(ctx, strings.TrimSpace(sessionID))
-	if err != nil || !confirmedLoopActionSessionDeath(info) {
+	if err != nil {
+		// Fail open: an unreadable session status must not block normal failure handling.
+		r.logger.Warn("daemon: read Loop action session status", "session_id", sessionID, "error", err)
+		return false, nil
+	}
+	if !confirmedLoopActionSessionDeath(info) {
 		return false, nil
 	}
📝 Committable suggestion

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

Suggested change
info, err := r.sessions.Status(ctx, strings.TrimSpace(sessionID))
if err != nil || !confirmedLoopActionSessionDeath(info) {
return false, nil
}
info, err := r.sessions.Status(ctx, strings.TrimSpace(sessionID))
if err != nil {
// Fail open: an unreadable session status must not block normal failure handling.
r.logger.Warn("daemon: read Loop action session status", "session_id", sessionID, "error", err)
return false, nil
}
if !confirmedLoopActionSessionDeath(info) {
return false, nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/daemon/loop_action_death_resume.go` around lines 25 - 28, Update the
Status call in the session-death resume flow to handle its error separately from
confirmedLoopActionSessionDeath: log or return the Status error with appropriate
context, and retain the existing false result only for a successful status check
showing the session is not dead. Document the intentional fail-open behavior if
the function continues suppressing the error.

Source: Coding guidelines

Comment on lines +226 to +239
result, controlled, err := r.executeClaimedRun(
ctx,
claim,
actor,
leaseDuration,
actionTimeout,
authoredTimeout,
timeoutReason,
looppkg.WorkspaceID(taskRecord.WorkspaceID),
silenceWindow,
deathStreakLimit,
)
if controlled {
return err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect executeClaimedRun to confirm lease completion/failure in the controlled branch.
set -euo pipefail

ast-grep run --pattern 'func (r *loopActionRuntime) executeClaimedRun($$$) {
  $$$
}' --lang go internal/daemon/loop_action_runtime.go

# Fallback broader search if the method lives in another file
rg -n -A 60 'func \(r \*loopActionRuntime\) executeClaimedRun' internal/daemon

Repository: compozy/compozy

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== matched files =="
git ls-files | rg '(^|/)loop_action_runtime\.go$|loop_action|action_runtime' || true

echo "== executeClaimedRun references =="
rg -n "executeClaimedRun|CompleteRunLease|failClaimedRun|controlled" internal/daemon || true

echo "== loop_action_runtime outline =="
ast-grep outline internal/daemon/loop_action_runtime.go --view compact 2>/dev/null || true

echo "== relevant loop_action_runtime lines =="
if [ -f internal/daemon/loop_action_runtime.go ]; then
  wc -l internal/daemon/loop_action_runtime.go
  sed -n '200,255p' internal/daemon/loop_action_runtime.go
fi

Repository: compozy/compozy

Length of output: 4288


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== loop_action_liveness executeClaimedRun section =="
wc -l internal/daemon/loop_action_liveness.go
sed -n '1,220p' internal/daemon/loop_action_liveness.go

echo "== behavioral verifier: controlled return lease handling =="
python3 - <<'PY'
from pathlib import Path
src = Path("internal/daemon/loop_action_liveness.go").read_text()
for pat in ["err = r.failClaimedRun", "CompleteRunLease"]:
    print(f"{pat}: {src.count(pat)}")
if "err := r.failClaimedRun" not in src and "CompleteRunLease" not in src:
    print("executeClaimedRun controlled branch does not show local lease completion/failure")
PY

Repository: compozy/compozy

Length of output: 6051


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== heartbeatClaim completion/failure references =="
sed -n '220,426p' internal/daemon/loop_action_liveness.go

echo "== all heartbeatClaim definitions/usages =="
rg -n "func \(.*heartbeatClaim|heartbeatClaim\\(|CompleteRunLease\\(|failClaimedRun\\(" internal/daemon -g '*.go' || true

echo "== failClaimedRun signature =="
sed -n '250,310p' internal/daemon/loop_action_runtime.go

echo "== exact controlled call context =="
python3 - <<'PY'
from pathlib import Path
p = Path("internal/daemon/loop_action_runtime.go")
s = p.read_text().splitlines()
for i, line in enumerate(s, 1):
    if "result, controlled, err := r.executeClaimedRun(" in line:
        start = max(1, i-8); end = min(len(s), i+22)
        print(f"\n--- loop_action_runtime.go:{start}-{end} ---")
        for j in range(start, end+1):
            print(f"{j:4}: {s[j-1]}")
PY

Repository: compozy/compozy

Length of output: 9898


Handle the lease when executeClaimedRun returns controlled = true.

When controlled is true, executeQueuedRun returns err immediately and skips failClaimedRun and CompleteRunLease. executeClaimedRun only starts heartbeatClaim and returns true for a handled dead action, but heartbeatClaim only heartbeats/extends the lease. Finalize the claimed lease in this branch, and document the controlled path clearly.

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

In `@internal/daemon/loop_action_runtime.go` around lines 226 - 239, Update the
controlled branch in executeQueuedRun after executeClaimedRun returns
controlled=true to finalize the claimed lease before returning, using the
existing failClaimedRun and CompleteRunLease flow as appropriate. Document that
this handled dead-action path is responsible for lease finalization, while
preserving the returned error behavior.

Comment on lines +29 to +56
cells, next, err := g.listDueLoopWaits(ctx, now, cursor, limit)
if err != nil {
return nil, looppkg.WaitDueCursor{}, err
}
runs := make([]taskpkg.Run, 0, len(cells))
for _, cell := range cells {
admissionAttempts, configErr := g.waitDueAdmissionAttempts(ctx, cell)
if configErr != nil {
return runs, next, configErr
}
result, resumeErr := g.ResumeWait(ctx, looppkg.WaitResumeMutation{
WorkspaceID: cell.workspaceID, RunID: cell.runID, Generation: cell.generation,
NodeID: cell.nodeID, ItemIndex: cell.itemIndex, Payload: []byte(`{}`),
ClaimedByKind: loopWaitClaimedByTimer, ClaimedByID: loopWaitClaimedByScheduler,
AdmissionAttempts: admissionAttempts, RequestedAt: now.UTC(),
})
if errors.Is(resumeErr, looppkg.ErrTransitionConflict) || errors.Is(resumeErr, looppkg.ErrInvalidTransition) {
continue
}
if resumeErr != nil {
return runs, next, resumeErr
}
if result.Won && result.Coordinator != nil {
runs = append(runs, *result.Coordinator)
}
}
return runs, next, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A mid-page error advances the scan cursor past unprocessed waits. The page functions return the end-of-page cursor together with a hard error, and the scheduler stores that cursor before it checks the error. Every wait between the failure point and the end of the page is then skipped for that pass, so due timers and escalations can stay unprocessed until the cursor resets on a short page.

  • internal/store/globaldb/global_db_loop_wait_due.go#L29-L56: on configErr and on resumeErr, return a cursor that points at the last successfully resumed cell, or return looppkg.WaitDueCursor{} instead of next.
  • internal/store/globaldb/global_db_loop_wait_escalation.go#L29-L42: apply the same change to the hard-error return, which currently returns next, err.
  • internal/daemon/scheduler_loop_due_scan.go#L112-L127: assign s.loopWaitDueScan.cursor = next only when err is nil.
  • internal/daemon/scheduler_loop_due_scan.go#L129-L144: assign s.loopWaitEscalationScan.cursor = next only when err is nil.
📍 Affects 3 files
  • internal/store/globaldb/global_db_loop_wait_due.go#L29-L56 (this comment)
  • internal/store/globaldb/global_db_loop_wait_escalation.go#L29-L42
  • internal/daemon/scheduler_loop_due_scan.go#L112-L127
  • internal/daemon/scheduler_loop_due_scan.go#L129-L144
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/globaldb/global_db_loop_wait_due.go` around lines 29 - 56,
Prevent failed mid-page scans from advancing past unprocessed waits: in
internal/store/globaldb/global_db_loop_wait_due.go lines 29-56, update the
configErr and resumeErr paths to return a cursor at the last successfully
resumed cell, or an empty looppkg.WaitDueCursor{} when none succeeded, instead
of next; in internal/store/globaldb/global_db_loop_wait_escalation.go lines
29-42, apply the same cursor handling to its hard-error return. In
internal/daemon/scheduler_loop_due_scan.go lines 112-127 and 129-144, update the
due and escalation scan flows to assign their cursors only when err is nil.

Comment on lines +58 to +80
func (g *LoopRepo) waitDueAdmissionAttempts(ctx context.Context, cell waitDueCell) (int, error) {
run, err := getLoopRunByIDWithExecutor(ctx, g.db, cell.runID)
if err != nil {
return 0, err
}
resolved, err := loadWaitExecutedDefinition(ctx, g.db, run)
if err != nil {
return 0, err
}
node, found := loopWaitNodeByRuntimeID(resolved.Definition.Graph, "", string(cell.nodeID))
if !found {
return 0, fmt.Errorf(
"%w: due Loop wait node %q is absent from the executed definition",
looppkg.ErrValidation,
cell.nodeID,
)
}
lifecycle, err := looppkg.ResolveNodeLifecycleConfig(node, nil, resolved.EffectiveConfig.Lifecycle)
if err != nil {
return 0, fmt.Errorf("store: resolve due Loop wait lifecycle: %w", err)
}
return lifecycle.WaitAdmissionAttempts, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Cache the executed definition per page to avoid repeated hydration.

waitDueAdmissionAttempts runs for every cell in the page. Each call loads the loop run, reads the definition snapshot JSON, and hydrates it through LoadExecutedDefinitionSnapshot. A page holds up to 1000 cells, and cells of one run share one definition_digest. The scan therefore repeats the same query and the same JSON decode many times on a scheduler tick.

Cache the resolved definition by definition_digest for the duration of one page, or resolve the admission attempts once per run.

♻️ Sketch of a per-page cache
 	runs := make([]taskpkg.Run, 0, len(cells))
+	resolvedCache := make(map[string]*looppkg.ResolvedDefinition, len(cells))
 	for _, cell := range cells {
-		admissionAttempts, configErr := g.waitDueAdmissionAttempts(ctx, cell)
+		admissionAttempts, configErr := g.waitDueAdmissionAttempts(ctx, cell, resolvedCache)

Inside waitDueAdmissionAttempts, look the digest up in the cache before calling loadWaitExecutedDefinition.

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

In `@internal/store/globaldb/global_db_loop_wait_due.go` around lines 58 - 80,
Cache the resolved executed definition for each page so waitDueAdmissionAttempts
does not repeatedly load and hydrate the same snapshot for cells sharing a
definition_digest. Add a page-scoped cache keyed by definition_digest, reuse
cached results before calling loadWaitExecutedDefinition, and preserve the
existing node lookup and lifecycle resolution behavior for each cell.

Comment on lines +160 to +180
err := exec.QueryRowContext(ctx, `SELECT output.status, output.epoch,
COALESCE(control.paused, 0), COALESCE(control.quarantined, 0),
COALESCE(control.cancel_state, '') FROM loop_generation_outputs AS output
LEFT JOIN loop_node_controls AS control ON control.loop_run_id = output.loop_run_id
AND control.node_id = output.node_id WHERE output.loop_run_id = ? AND output.generation = ?
AND output.node_id = ? AND output.item_index = ?`, cell.runID, cell.generation,
cell.nodeID, cell.itemIndex).Scan(&status, &epoch, &paused, &quarantined, &cancelState)
if err != nil {
return fmt.Errorf("store: load due wait escalation cell: %w", err)
}
wantStatus := loopNodeOutputWaiting
if wait.Kind == looppkg.NodeWaitKindApprovalEscalation {
wantStatus = loopNodeOutputSucceeded
if cell.nodeID != wait.NodeID {
return fmt.Errorf("%w: approval wait identity changed", looppkg.ErrTransitionConflict)
}
}
if status != wantStatus || epoch != wait.IssuedEpoch || paused != 0 || quarantined != 0 ||
strings.TrimSpace(cancelState) != "" {
return fmt.Errorf("%w: wait escalation cell changed", looppkg.ErrTransitionConflict)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Map a missing output row to a transition conflict.

validateDueWaitEscalationCell treats sql.ErrNoRows like any other database failure. The outer scan in listDueLoopWaitEscalations selects the cell through a join on loop_generation_outputs, so the row can disappear between the scan and this transaction. EscalateDueLoopWaitsPage only skips ErrTransitionConflict and ErrInvalidTransition. A single racing deletion therefore aborts the whole escalation page instead of skipping one cell.

Return looppkg.ErrTransitionConflict when the row is absent.

🐛 Proposed fix
 		cell.nodeID, cell.itemIndex).Scan(&status, &epoch, &paused, &quarantined, &cancelState)
+	if errors.Is(err, sql.ErrNoRows) {
+		return fmt.Errorf("%w: wait escalation cell is absent", looppkg.ErrTransitionConflict)
+	}
 	if err != nil {
 		return fmt.Errorf("store: load due wait escalation cell: %w", err)
 	}
📝 Committable suggestion

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

Suggested change
err := exec.QueryRowContext(ctx, `SELECT output.status, output.epoch,
COALESCE(control.paused, 0), COALESCE(control.quarantined, 0),
COALESCE(control.cancel_state, '') FROM loop_generation_outputs AS output
LEFT JOIN loop_node_controls AS control ON control.loop_run_id = output.loop_run_id
AND control.node_id = output.node_id WHERE output.loop_run_id = ? AND output.generation = ?
AND output.node_id = ? AND output.item_index = ?`, cell.runID, cell.generation,
cell.nodeID, cell.itemIndex).Scan(&status, &epoch, &paused, &quarantined, &cancelState)
if err != nil {
return fmt.Errorf("store: load due wait escalation cell: %w", err)
}
wantStatus := loopNodeOutputWaiting
if wait.Kind == looppkg.NodeWaitKindApprovalEscalation {
wantStatus = loopNodeOutputSucceeded
if cell.nodeID != wait.NodeID {
return fmt.Errorf("%w: approval wait identity changed", looppkg.ErrTransitionConflict)
}
}
if status != wantStatus || epoch != wait.IssuedEpoch || paused != 0 || quarantined != 0 ||
strings.TrimSpace(cancelState) != "" {
return fmt.Errorf("%w: wait escalation cell changed", looppkg.ErrTransitionConflict)
}
err := exec.QueryRowContext(ctx, `SELECT output.status, output.epoch,
COALESCE(control.paused, 0), COALESCE(control.quarantined, 0),
COALESCE(control.cancel_state, '') FROM loop_generation_outputs AS output
LEFT JOIN loop_node_controls AS control ON control.loop_run_id = output.loop_run_id
AND control.node_id = output.node_id WHERE output.loop_run_id = ? AND output.generation = ?
AND output.node_id = ? AND output.item_index = ?`, cell.runID, cell.generation,
cell.nodeID, cell.itemIndex).Scan(&status, &epoch, &paused, &quarantined, &cancelState)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: wait escalation cell is absent", looppkg.ErrTransitionConflict)
}
if err != nil {
return fmt.Errorf("store: load due wait escalation cell: %w", err)
}
wantStatus := loopNodeOutputWaiting
if wait.Kind == looppkg.NodeWaitKindApprovalEscalation {
wantStatus = loopNodeOutputSucceeded
if cell.nodeID != wait.NodeID {
return fmt.Errorf("%w: approval wait identity changed", looppkg.ErrTransitionConflict)
}
}
if status != wantStatus || epoch != wait.IssuedEpoch || paused != 0 || quarantined != 0 ||
strings.TrimSpace(cancelState) != "" {
return fmt.Errorf("%w: wait escalation cell changed", looppkg.ErrTransitionConflict)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/globaldb/global_db_loop_wait_escalation_apply.go` around lines
160 - 180, Update validateDueWaitEscalationCell’s QueryRowContext scan handling
so sql.ErrNoRows is wrapped or returned as looppkg.ErrTransitionConflict, while
preserving the existing store error for other database failures. This allows
EscalateDueLoopWaitsPage to skip cells whose output row was deleted
concurrently.

Comment on lines +12 to +49
func protectCanceledLoopFromStaleCoordinator(
ctx context.Context,
exec taskSQLExecutor,
completion *taskpkg.CoordinatorCompletion,
loopRunID string,
) (bool, error) {
if completion == nil {
return false, fmt.Errorf("%w: coordinator completion is required", taskpkg.ErrValidation)
}
run, err := getLoopRunByIDWithExecutor(ctx, exec, looppkg.RunID(strings.TrimSpace(loopRunID)))
if err != nil {
return false, err
}
if !run.CancelRequested || completion.Plan.CancellationDrain {
return false, nil
}
plan := taskpkg.CoordinatorCompletionPlan{
Snapshot: taskpkg.GenerationSnapshot{
LoopRunID: string(run.ID),
Generation: max(1, run.Generation),
},
Yield: true,
CancellationDrain: true,
}
if !run.Status.Terminal() {
plan.GenerationInFlight = true
plan.PostCommitWakes = []taskpkg.CoordinatorWakeSpec{{
LoopRunID: string(run.ID),
IdempotencyKey: fmt.Sprintf(
"loop.coordinator.cancel.%s.%s",
run.ID,
strings.TrimSpace(completion.RunID),
),
}}
}
completion.Plan = plan
return true, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -A 40 'func .*withTaskImmediateTransaction' internal/store/globaldb

Repository: compozy/compozy

Length of output: 2670


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== task transaction implementation =="
sed -n '1,80p' internal/store/globaldb/global_db_task_transaction.go

echo
echo "== task coordinator completion files outline/search relevant symbols =="
rg -n "CompleteCoordinatorAndEnqueueNext|completeCoordinatorAndEnqueueNextWithExecutor|protectCanceledLoopFromStaleCoordinator|finishCoordinatorRun|PlanSuperseded|withTaskImmediateTransaction|StoreWrite" internal/store/globaldb internal/task/manager_coordinator_run.go internal/store 2>/dev/null | head -200

echo
echo "== coordinator.go relevant sections =="
sed -n '1,150p' internal/store/globaldb/global_db_task_coordinator.go
sed -n '1,70p' internal/store/globaldb/global_db_task_coordinator_cancellation.go

echo
echo "== manager_coordinator_run.go relevant sections =="
sed -n '1,220p' internal/task/manager_coordinator_run.go

echo
echo "== global WriteTx transaction semantics =="
grep -R "func ExecuteWrite|type WriteTx|retry|transient|Backoff|time\.After|sleep" internal/store -n | head -200

Repository: compozy/compozy

Length of output: 42083


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate store/write transaction implementation =="
rg -n -A 80 '^func ExecuteWrite|type WriteTx|ExecuteImmediate|BeginImmediate|retry|Backoff|time\.After|sleep' internal/store -g '*.go' | head -300

echo
echo "== transaction helper implementations =="
sed -n '113,145p' internal/store/globaldb/tx_helpers.go

echo
echo "== CompleteCoordinatorAndEnqueueNext executor body relevant section =="
sed -n '62,230p' internal/store/globaldb/global_db_task_coordinator.go

echo
echo "== CoordinatorCompletion definition and result fields =="
rg -n "type CoordinatorCompletion|type CoordinatorCompletionResult|PlanSuperseded|CancellationDrain|PostCommitTimers|ParentCloses" internal/task -g '*.go' internal/store/globaldb -g '*.go' | head -220

Repository: compozy/compozy

Length of output: 26617


Reset the mutated coordinator-completion pointer before retrying the transaction closure.

CompleteCoordinatorAndEnqueueNext reuses the same &normalized pointer in the transaction closure. protectCanceledLoopFromStaleCoordinator sets completion.Plan.CancellationDrain in place. Since executeWrite retries the callback for SQLITE_BUSY/SQLITE_LOCKED, the committed retry can see the stale mutation and set PlanSuperseded=false; finishCoordinatorRun then applies the original plan’s parent closes/timers on the canceled loop. Clone the completion/plan before passing it into the executor, or make the supervisor decision idempotent from run.CancelRequested without relying on the mutated Plan.

📍 Affects 2 files
  • internal/store/globaldb/global_db_task_coordinator_cancellation.go#L12-L49 (this comment)
  • internal/store/globaldb/global_db_task_coordinator.go#L18-L61
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/globaldb/global_db_task_coordinator_cancellation.go` around
lines 12 - 49, Make the coordinator completion decision retry-safe across
protectCanceledLoopFromStaleCoordinator and CompleteCoordinatorAndEnqueueNext:
avoid reusing the same mutable completion/Plan pointer across executeWrite
retries by cloning the normalized completion (including its Plan) for each
transaction attempt, or otherwise derive the cancellation-supervisor decision
idempotently from run.CancelRequested. Update
internal/store/globaldb/global_db_task_coordinator_cancellation.go lines 12-49
and internal/store/globaldb/global_db_task_coordinator.go lines 18-61 so
finishCoordinatorRun cannot apply the original parent closes or timers after
cancellation.

Comment on lines +24 to +46
for _, raw := range plan.ParentCloses {
spec := raw.Normalize()
child, err := getLoopRunByIDWithExecutor(ctx, exec, looppkg.RunID(spec.ChildLoopRunID))
if err != nil {
return err
}
if child.ParentLoopRunID != parentRunID || spec.ParentLoopRunID != string(parentRunID) {
return fmt.Errorf(
"%w: child Loop run %q is not owned by parent %q",
looppkg.ErrTransitionConflict,
child.ID,
parentRunID,
)
}
if spec.ParentStatus != strings.TrimSpace(plan.Terminal.Status) {
return fmt.Errorf(
"%w: parent-close status %q does not match terminal %q",
taskpkg.ErrValidation,
spec.ParentStatus,
plan.Terminal.Status,
)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Validate spec.Policy against the closed policy set.

This function is the store-side guard for parent-close actions. It checks ownership and parent status but never checks the policy value. An empty or unknown policy passes validation and reaches the mutation path, where the effect on the child run is undefined. Reject any policy outside the authored set. Decide explicitly whether abandon is accepted here; the coordinator filters it out before building intents, so accepting it at the store level would create two different meanings for the same field.

🛡️ Proposed guard
 	for _, raw := range plan.ParentCloses {
 		spec := raw.Normalize()
+		switch spec.Policy {
+		case string(dsl.ParentCloseTerminate), string(dsl.ParentCloseCancel):
+		default:
+			return fmt.Errorf(
+				"%w: parent-close policy %q is not supported",
+				taskpkg.ErrValidation,
+				spec.Policy,
+			)
+		}
 		child, err := getLoopRunByIDWithExecutor(ctx, exec, looppkg.RunID(spec.ChildLoopRunID))

Add the dsl import, or compare against the policy constants that Normalize uses.

📝 Committable suggestion

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

Suggested change
for _, raw := range plan.ParentCloses {
spec := raw.Normalize()
child, err := getLoopRunByIDWithExecutor(ctx, exec, looppkg.RunID(spec.ChildLoopRunID))
if err != nil {
return err
}
if child.ParentLoopRunID != parentRunID || spec.ParentLoopRunID != string(parentRunID) {
return fmt.Errorf(
"%w: child Loop run %q is not owned by parent %q",
looppkg.ErrTransitionConflict,
child.ID,
parentRunID,
)
}
if spec.ParentStatus != strings.TrimSpace(plan.Terminal.Status) {
return fmt.Errorf(
"%w: parent-close status %q does not match terminal %q",
taskpkg.ErrValidation,
spec.ParentStatus,
plan.Terminal.Status,
)
}
}
for _, raw := range plan.ParentCloses {
spec := raw.Normalize()
switch spec.Policy {
case string(dsl.ParentCloseTerminate), string(dsl.ParentCloseCancel):
default:
return fmt.Errorf(
"%w: parent-close policy %q is not supported",
taskpkg.ErrValidation,
spec.Policy,
)
}
child, err := getLoopRunByIDWithExecutor(ctx, exec, looppkg.RunID(spec.ChildLoopRunID))
if err != nil {
return err
}
if child.ParentLoopRunID != parentRunID || spec.ParentLoopRunID != string(parentRunID) {
return fmt.Errorf(
"%w: child Loop run %q is not owned by parent %q",
looppkg.ErrTransitionConflict,
child.ID,
parentRunID,
)
}
if spec.ParentStatus != strings.TrimSpace(plan.Terminal.Status) {
return fmt.Errorf(
"%w: parent-close status %q does not match terminal %q",
taskpkg.ErrValidation,
spec.ParentStatus,
plan.Terminal.Status,
)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/globaldb/global_db_task_coordinator_parent_close.go` around
lines 24 - 46, In the parent-close validation loop, add validation for
spec.Policy against the authored closed-policy set using the existing DSL policy
constants or Normalize-related definitions. Reject empty and unknown policies
before mutation, and explicitly exclude abandon to match the coordinator’s
filtering behavior. Return the established validation error type consistently
with the existing parent-status check.

* feat: Schema, classification, DSL grammar and config contracts #1

Checkpoint via cy-loop-tasks (iteration 2, phase B mode=tasks).

Co-Authored-By: Codex <[email protected]>

* feat: implement loop node failure precedence

* feat: Effects: outbox, relay and delivery identity #3

Checkpoint via cy-loop-tasks (iteration 4, phase B mode=tasks).

Co-Authored-By: Codex <[email protected]>

* feat: Target health breaker, quarantine and requeue #4

Checkpoint via cy-loop-tasks (iteration 5, phase B mode=tasks).

Co-Authored-By: Codex <[email protected]>

* fix: resolve CodeRabbit lifecycle review

* fix: address incremental CodeRabbit feedback

---------

Co-authored-by: Codex <[email protected]>
Base automatically changed from stack/loop-lifecycle-v2-01-runtime to main August 3, 2026 23:45
pedronauck and others added 4 commits August 3, 2026 20:48
Checkpoint via cy-loop-tasks (iteration 7, phase B mode=tasks).

Co-Authored-By: Codex <[email protected]>
Checkpoint via cy-loop-tasks (iteration 9, phase B mode=tasks).

Co-Authored-By: Codex <[email protected]>
Checkpoint via cy-loop-tasks (iteration 10, phase B mode=tasks).

Co-Authored-By: Codex <[email protected]>
@pedronauck
pedronauck force-pushed the stack/loop-lifecycle-v2-02-controls-surfaces branch from 8b9b224 to 05fd58d Compare August 3, 2026 23:58

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
internal/loop/coordinator_lifecycle_test.go (1)

917-1030: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Split this test into t.Run("Should …") subtests.

TestCoordinatorQuarantineShouldPreserveDiagnosableSanitizedHistory puts its assertions directly in the test body. It also covers two independent scenarios: sanitized appended history at lines 922-991, and bounded encoding of an oversized entry at lines 993-1029. If the first scenario fails, the second never runs.

Wrap each scenario in its own t.Run("Should …", ...) subtest with t.Parallel(), as every other test in this file does.

As per path instructions: "MUST use t.Run("Should...") pattern for ALL test cases". As per coding guidelines: "use t.Run("Should …"), default t.Parallel".

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

In `@internal/loop/coordinator_lifecycle_test.go` around lines 917 - 1030, Split
TestCoordinatorQuarantineShouldPreserveDiagnosableSanitizedHistory into two
t.Run("Should …", ...) subtests: one for sanitized appended history and one for
bounded oversized encoding. Move each scenario’s setup and assertions into its
subtest and call t.Parallel() within both subtests, while preserving the
existing test behavior and assertions.

Sources: Coding guidelines, Path instructions

🧹 Nitpick comments (2)
internal/cli/client_loops.go (1)

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

Consider folding loopRunAction into loopRunLifecycleAction.

loopRunAction at lines 306-316 builds the same path and issues the same request. Only the decoded response differs. Keeping both lets the path construction diverge later. loopRunAction can call loopRunLifecycleAction and discard the response.

♻️ Proposed refactor
 func (c *unixSocketClient) loopRunAction(
 	ctx context.Context,
 	workspaceID string,
 	runID string,
 	action string,
 	request any,
 	credentials agentidentity.Credentials,
 ) error {
-	path := loopRunPath(workspaceID, runID) + "/" + strings.TrimSpace(action)
-	return c.doAgentJSON(ctx, http.MethodPost, path, nil, request, credentials, nil)
+	_, err := c.loopRunLifecycleAction(ctx, workspaceID, runID, action, request, credentials)
+	return err
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/client_loops.go` around lines 318 - 332, Refactor loopRunAction
to delegate to loopRunLifecycleAction, reusing its path construction and POST
request logic while discarding the returned LoopMutationResponse. Preserve
loopRunAction’s existing error propagation and public behavior, and leave
loopRunLifecycleAction as the single implementation for this request flow.
internal/loop/service_test.go (1)

2169-2182: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that a terminal run performs no cancellation delivery.

This subtest confirms the status stays done. It does not confirm that the service skipped delivery and state advancement. A regression that still delivers cancellation to a finished run would pass. Add an assertion on store.cancellationStates.

♻️ Proposed additional assertion
 		if got := store.mustRun(t, run.ID).Status; got != loop.StatusDone {
 			t.Fatalf("stored status = %q, want done", got)
 		}
+		if len(store.cancellationStates) != 0 {
+			t.Fatalf("cancellation states = %#v, want no delivery on a terminal run", store.cancellationStates)
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/loop/service_test.go` around lines 2169 - 2182, Extend the “Should
preserve a terminal completion that wins the race” subtest to assert that no
cancellation delivery or state advancement occurred by checking
store.cancellationStates. Keep the existing StatusDone assertion and verify the
cancellation state remains absent or unchanged for the completed run.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/loop/coordinator_lifecycle_test.go`:
- Around line 671-675: Fix the NodeAttempt value in the lifecycle test by
replacing the invalid new(now) assignment for EndedAt with an addressable
pointer to the existing now time.Time variable, while leaving the other attempt
fields unchanged.

---

Outside diff comments:
In `@internal/loop/coordinator_lifecycle_test.go`:
- Around line 917-1030: Split
TestCoordinatorQuarantineShouldPreserveDiagnosableSanitizedHistory into two
t.Run("Should …", ...) subtests: one for sanitized appended history and one for
bounded oversized encoding. Move each scenario’s setup and assertions into its
subtest and call t.Parallel() within both subtests, while preserving the
existing test behavior and assertions.

---

Nitpick comments:
In `@internal/cli/client_loops.go`:
- Around line 318-332: Refactor loopRunAction to delegate to
loopRunLifecycleAction, reusing its path construction and POST request logic
while discarding the returned LoopMutationResponse. Preserve loopRunAction’s
existing error propagation and public behavior, and leave loopRunLifecycleAction
as the single implementation for this request flow.

In `@internal/loop/service_test.go`:
- Around line 2169-2182: Extend the “Should preserve a terminal completion that
wins the race” subtest to assert that no cancellation delivery or state
advancement occurred by checking store.cancellationStates. Keep the existing
StatusDone assertion and verify the cancellation state remains absent or
unchanged for the completed run.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ac172400-a546-4362-901a-ded129c250bd

📥 Commits

Reviewing files that changed from the base of the PR and between 8b9b224 and 05fd58d.

⛔ Files ignored due to path filters (26)
  • docs/qa/bugs/BUG-20260802-initial-wait-fails-run.md is excluded by !**/*.md
  • docs/qa/bugs/BUG-20260802-node-kill-leaves-run-live.md is excluded by !**/*.md
  • docs/qa/bugs/BUG-20260802-parked-node-cancel-stalls.md is excluded by !**/*.md
  • docs/qa/charters/CH-003.md is excluded by !**/*.md
  • docs/qa/charters/CH-agent-loop-lifecycle-native.md is excluded by !**/*.md
  • docs/qa/charters/CH-loop-death-resume.md is excluded by !**/*.md
  • docs/qa/charters/CH-loop-run-cancel-control.md is excluded by !**/*.md
  • docs/qa/charters/CH-runaway-work-bounded.md is excluded by !**/*.md
  • docs/qa/journeys/J-04-operator-pause-resume.md is excluded by !**/*.md
  • docs/qa/journeys/J-07-agent-operated-run.md is excluded by !**/*.md
  • docs/qa/journeys/J-bound-runaway-work.md is excluded by !**/*.md
  • docs/qa/journeys/J-resume-dead-loop-node.md is excluded by !**/*.md
  • docs/qa/reports/2026-08-02-loop-node-lifecycle-task07.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-016.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-agent-operates-lifecycle-via-native-tools.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-approval-link-journey.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-cancel-vs-kill.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-crash-death-resume.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-days-long-node-no-clock.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-duplicate-event-suppressed.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-durable-wait-restart.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-live-pause-repair-resume.md is excluded by !**/*.md
  • docs/qa/scenarios/LP-waiting-inventory-escalation.md is excluded by !**/*.md
  • docs/qa/scenarios/TA-076.md is excluded by !**/*.md
  • docs/qa/scenarios/TA-084.md is excluded by !**/*.md
  • docs/qa/scenarios/TA-action-run-liveness.md is excluded by !**/*.md
📒 Files selected for processing (155)
  • internal/api/contract/generic_error_payload.go
  • internal/api/contract/loop_enums.go
  • internal/api/contract/loop_nodes.go
  • internal/api/contract/loop_runs.go
  • internal/api/contract/loops.go
  • internal/api/core/errors_diagnostic_payload.go
  • internal/api/core/errors_test.go
  • internal/api/core/loop_interfaces.go
  • internal/api/core/loops.go
  • internal/api/core/loops_nodes.go
  • internal/api/core/loops_test.go
  • internal/api/httpapi/handlers_test.go
  • internal/api/httpapi/loops_routes.go
  • internal/api/spec/loops.go
  • internal/api/spec/loops_nodes.go
  • internal/api/spec/loops_test.go
  • internal/api/spec/schema_enum_registry.go
  • internal/api/udsapi/handlers_test.go
  • internal/api/udsapi/loops_routes.go
  • internal/automation/dispatch_loop.go
  • internal/cli/client_loops.go
  • internal/cli/helpers_test.go
  • internal/cli/loop.go
  • internal/cli/loop_client.go
  • internal/cli/loop_inspect_output.go
  • internal/cli/loop_node.go
  • internal/cli/loop_nodes.go
  • internal/cli/loop_runs.go
  • internal/cli/loop_support.go
  • internal/cli/loop_test.go
  • internal/cli/marketplace.go
  • internal/config/merge_task.go
  • internal/config/task_orchestration.go
  • internal/config/task_orchestration_test.go
  • internal/config/task_orchestration_validation.go
  • internal/config/tool_surface_task.go
  • internal/config/tool_surface_test.go
  • internal/daemon/automation_loop_starter.go
  • internal/daemon/loop_action_checkpoint.go
  • internal/daemon/loop_action_death_resume.go
  • internal/daemon/loop_action_liveness.go
  • internal/daemon/loop_action_liveness_integration_helpers_test.go
  • internal/daemon/loop_action_runtime.go
  • internal/daemon/loop_api_contract.go
  • internal/daemon/loop_api_nodes.go
  • internal/daemon/loop_api_payloads_test.go
  • internal/daemon/loop_api_run_history.go
  • internal/daemon/loop_api_runs.go
  • internal/daemon/loop_api_runs_test.go
  • internal/daemon/loop_api_service.go
  • internal/daemon/loop_goal_managed_runtime_integration_test.go
  • internal/daemon/loop_hook_observer.go
  • internal/daemon/loop_hook_observer_test.go
  • internal/daemon/loop_managed_binding.go
  • internal/daemon/loop_parent_close_postcommit.go
  • internal/daemon/loop_watch_events_doorbell.go
  • internal/daemon/loop_watch_events_observer.go
  • internal/daemon/loop_watch_events_observer_index.go
  • internal/daemon/loop_watch_events_observer_test.go
  • internal/daemon/loop_watch_events_payloads.go
  • internal/daemon/native_hook_helpers.go
  • internal/daemon/native_loop_lifecycle_support.go
  • internal/daemon/native_loop_lifecycle_tools.go
  • internal/daemon/native_loop_tools.go
  • internal/daemon/native_loop_tools_support.go
  • internal/daemon/native_loop_tools_test.go
  • internal/daemon/scheduler_loop_coordinator.go
  • internal/daemon/scheduler_loop_due_scan.go
  • internal/daemon/scheduler_runtime.go
  • internal/daemon/scheduler_task_source_types.go
  • internal/daemon/task_runtime_boot.go
  • internal/daemon/task_runtime_test.go
  • internal/extension/scaffold_templates/loop-watch-source-go/main.go
  • internal/extension/watch_source.go
  • internal/extension/watch_source_test.go
  • internal/hooks/payloads_task_loop.go
  • internal/loop/action_failure.go
  • internal/loop/action_retry_feedback.go
  • internal/loop/action_runagent.go
  • internal/loop/action_schema.go
  • internal/loop/action_test.go
  • internal/loop/action_types.go
  • internal/loop/action_usage.go
  • internal/loop/admission_claim.go
  • internal/loop/cancel_control.go
  • internal/loop/compiler.go
  • internal/loop/control_eval_context.go
  • internal/loop/control_plan.go
  • internal/loop/control_plan_branch.go
  • internal/loop/control_plan_finish.go
  • internal/loop/control_topology.go
  • internal/loop/coordinator.go
  • internal/loop/coordinator_action.go
  • internal/loop/coordinator_action_liveness.go
  • internal/loop/coordinator_action_metadata.go
  • internal/loop/coordinator_autopause.go
  • internal/loop/coordinator_cancellation.go
  • internal/loop/coordinator_control_test.go
  • internal/loop/coordinator_effects.go
  • internal/loop/coordinator_error_routes.go
  • internal/loop/coordinator_gate_wait.go
  • internal/loop/coordinator_generation.go
  • internal/loop/coordinator_lifecycle.go
  • internal/loop/coordinator_lifecycle_test.go
  • internal/loop/coordinator_metadata.go
  • internal/loop/coordinator_outputs.go
  • internal/loop/coordinator_pause.go
  • internal/loop/coordinator_quarantine.go
  • internal/loop/coordinator_test.go
  • internal/loop/coordinator_wait.go
  • internal/loop/coordinator_wait_route.go
  • internal/loop/coordinator_watch_test.go
  • internal/loop/death_resume.go
  • internal/loop/dsl/graph.go
  • internal/loop/effects_dispatch.go
  • internal/loop/effects_dispatch_test.go
  • internal/loop/errors.go
  • internal/loop/executed_definition_validation.go
  • internal/loop/executed_definition_watch.go
  • internal/loop/generation_snapshot.go
  • internal/loop/generation_snapshot_controls.go
  • internal/loop/generation_snapshot_intents.go
  • internal/loop/goal_cancellation.go
  • internal/loop/linter.go
  • internal/loop/linter_references.go
  • internal/loop/linter_test.go
  • internal/loop/linter_wait.go
  • internal/loop/node_control_mutation.go
  • internal/loop/node_inventory.go
  • internal/loop/node_liveness.go
  • internal/loop/node_pause.go
  • internal/loop/node_wait.go
  • internal/loop/node_waits.go
  • internal/loop/parent_close.go
  • internal/loop/run_network_state.go
  • internal/loop/service.go
  • internal/loop/service_cancellation.go
  • internal/loop/service_control.go
  • internal/loop/service_node_pause.go
  • internal/loop/service_node_requeue.go
  • internal/loop/service_options.go
  • internal/loop/service_start.go
  • internal/loop/service_test.go
  • internal/loop/service_types.go
  • internal/loop/wait_due.go
  • internal/loop/watch/adapter.go
  • internal/loop/watch/adapter_test.go
  • internal/loop/watch/event_key.go
  • internal/loop/watch/output.go
  • internal/loop/watch/types.go
  • internal/loop/watch_events_constraints.go
  • internal/loop/watch_events_contract.go
  • internal/loop/watch_events_ledger.go
  • internal/session/manager_prompt_submit.go
  • internal/session/manager_test.go
💤 Files with no reviewable changes (7)
  • internal/config/tool_surface_task.go
  • internal/config/task_orchestration_test.go
  • internal/config/task_orchestration_validation.go
  • internal/api/core/loops.go
  • internal/config/merge_task.go
  • internal/loop/service_control.go
  • internal/config/task_orchestration.go
🚧 Files skipped from review as they are similar to previous changes (143)
  • internal/loop/coordinator_quarantine.go
  • internal/daemon/loop_watch_events_doorbell.go
  • internal/session/manager_test.go
  • internal/session/manager_prompt_submit.go
  • internal/daemon/native_hook_helpers.go
  • internal/loop/watch/types.go
  • internal/cli/marketplace.go
  • internal/loop/linter.go
  • internal/daemon/loop_api_runs_test.go
  • internal/loop/watch/event_key.go
  • internal/loop/linter_references.go
  • internal/loop/action_retry_feedback.go
  • internal/loop/control_topology.go
  • internal/config/tool_surface_test.go
  • internal/loop/node_wait.go
  • internal/daemon/loop_managed_binding.go
  • internal/hooks/payloads_task_loop.go
  • internal/loop/coordinator_error_routes.go
  • internal/api/core/errors_test.go
  • internal/daemon/loop_hook_observer.go
  • internal/loop/executed_definition_validation.go
  • internal/loop/effects_dispatch.go
  • internal/daemon/scheduler_task_source_types.go
  • internal/daemon/loop_action_checkpoint.go
  • internal/loop/action_types.go
  • internal/loop/coordinator_cancellation.go
  • internal/api/spec/loops_nodes.go
  • internal/loop/watch_events_contract.go
  • internal/loop/control_plan_finish.go
  • internal/loop/coordinator_effects.go
  • internal/daemon/loop_watch_events_payloads.go
  • internal/extension/scaffold_templates/loop-watch-source-go/main.go
  • internal/cli/loop_nodes.go
  • internal/loop/effects_dispatch_test.go
  • internal/loop/watch/output.go
  • internal/loop/watch_events_constraints.go
  • internal/loop/coordinator_action_metadata.go
  • internal/loop/action_usage.go
  • internal/loop/action_test.go
  • internal/daemon/loop_watch_events_observer_index.go
  • internal/cli/loop_support.go
  • internal/loop/watch/adapter.go
  • internal/cli/loop_test.go
  • internal/daemon/loop_action_death_resume.go
  • internal/loop/coordinator_pause.go
  • internal/loop/service_options.go
  • internal/cli/loop.go
  • internal/loop/action_runagent.go
  • internal/loop/errors.go
  • internal/daemon/scheduler_runtime.go
  • internal/loop/linter_test.go
  • internal/loop/service_start.go
  • internal/api/core/errors_diagnostic_payload.go
  • internal/loop/linter_wait.go
  • internal/loop/coordinator_autopause.go
  • internal/api/contract/generic_error_payload.go
  • internal/extension/watch_source.go
  • internal/api/httpapi/handlers_test.go
  • internal/daemon/loop_api_run_history.go
  • internal/loop/coordinator_gate_wait.go
  • internal/api/spec/loops_test.go
  • internal/loop/generation_snapshot.go
  • internal/daemon/loop_api_payloads_test.go
  • internal/loop/control_eval_context.go
  • internal/api/udsapi/loops_routes.go
  • internal/loop/run_network_state.go
  • internal/daemon/loop_goal_managed_runtime_integration_test.go
  • internal/cli/loop_node.go
  • internal/daemon/loop_api_runs.go
  • internal/api/contract/loop_nodes.go
  • internal/daemon/loop_hook_observer_test.go
  • internal/daemon/native_loop_lifecycle_support.go
  • internal/loop/coordinator_metadata.go
  • internal/api/udsapi/handlers_test.go
  • internal/cli/loop_inspect_output.go
  • internal/loop/control_plan.go
  • internal/loop/generation_snapshot_intents.go
  • internal/loop/watch/adapter_test.go
  • internal/loop/service_node_requeue.go
  • internal/api/core/loops_test.go
  • internal/cli/loop_client.go
  • internal/daemon/loop_action_runtime.go
  • internal/daemon/loop_api_service.go
  • internal/extension/watch_source_test.go
  • internal/loop/coordinator.go
  • internal/loop/watch_events_ledger.go
  • internal/daemon/loop_action_liveness_integration_helpers_test.go
  • internal/daemon/loop_watch_events_observer.go
  • internal/loop/cancel_control.go
  • internal/loop/service_node_pause.go
  • internal/daemon/task_runtime_boot.go
  • internal/daemon/scheduler_loop_coordinator.go
  • internal/loop/dsl/graph.go
  • internal/loop/wait_due.go
  • internal/loop/node_liveness.go
  • internal/loop/node_control_mutation.go
  • internal/loop/control_plan_branch.go
  • internal/loop/node_pause.go
  • internal/loop/coordinator_outputs.go
  • internal/api/httpapi/loops_routes.go
  • internal/daemon/loop_watch_events_observer_test.go
  • internal/loop/action_schema.go
  • internal/loop/coordinator_wait.go
  • internal/loop/coordinator_test.go
  • internal/daemon/native_loop_tools.go
  • internal/loop/coordinator_wait_route.go
  • internal/api/spec/schema_enum_registry.go
  • internal/api/core/loops_nodes.go
  • internal/daemon/native_loop_lifecycle_tools.go
  • internal/loop/compiler.go
  • internal/cli/helpers_test.go
  • internal/loop/node_inventory.go
  • internal/daemon/native_loop_tools_support.go
  • internal/loop/coordinator_watch_test.go
  • internal/loop/coordinator_action_liveness.go
  • internal/loop/service_cancellation.go
  • internal/loop/coordinator_control_test.go
  • internal/loop/admission_claim.go
  • internal/loop/coordinator_action.go
  • internal/daemon/loop_api_contract.go
  • internal/daemon/native_loop_tools_test.go
  • internal/api/contract/loop_runs.go
  • internal/daemon/loop_api_nodes.go
  • internal/loop/death_resume.go
  • internal/loop/generation_snapshot_controls.go
  • internal/loop/goal_cancellation.go
  • internal/loop/service.go
  • internal/loop/parent_close.go
  • internal/loop/coordinator_generation.go
  • internal/daemon/loop_parent_close_postcommit.go
  • internal/loop/executed_definition_watch.go
  • internal/api/spec/loops.go
  • internal/cli/loop_runs.go
  • internal/daemon/automation_loop_starter.go
  • internal/api/contract/loop_enums.go
  • internal/loop/coordinator_lifecycle.go
  • internal/loop/node_waits.go
  • internal/daemon/loop_action_liveness.go
  • internal/loop/action_failure.go
  • internal/api/core/loop_interfaces.go
  • internal/automation/dispatch_loop.go
  • internal/daemon/scheduler_loop_due_scan.go
  • internal/loop/service_types.go

Comment on lines +671 to +675
attempts: []NodeAttempt{{
LoopRunID: loopRun.ID, Generation: 1, NodeID: "work", Attempt: 1,
FailureClass: &failureClass, Disposition: AttemptRetried,
StartedAt: now.Add(-time.Minute), EndedAt: new(now), NextAttemptAt: &nextAttemptAt,
}},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether the loop package shadows the `new` builtin, and inspect the exact line.
set -euo pipefail

echo "== package-level identifiers named new =="
ast-grep run --pattern 'func new($$$) $_ { $$$ }' --lang go internal/loop || true
rg -n --type=go '^(var|func|type)\s+new\b' internal/loop || echo "no package-level 'new' declaration found"

echo "== the reported line =="
rg -n 'new\(now\)' internal/loop

Repository: compozy/compozy

Length of output: 396


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== scoped `new` binding searches in internal/loop =="
rg -n --type=go '\bnew\s*\(' internal/loop || true
rg -n --type=go '^\s*(const|var|func|type)\s+new\b' internal/loop || true

echo "== imports and package declarations around coordinator_lifecycle_test.go =="
sed -n '1,80p' internal/loop/coordinator_lifecycle_test.go

echo "== nearby local shadow candidates in current function scope =="
sed -n '640,690p' internal/loop/coordinator_lifecycle_test.go

Repository: compozy/compozy

Length of output: 14341


Use an addressable time.Time value before assigning EndedAt.

new(now) passes the time.Time variable now as if it were a type, so the compiler rejects this call. EndedAt is a pointer field; use EndedAt: &now in this value literal now that the value does not need to remain mutable after the call.

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

In `@internal/loop/coordinator_lifecycle_test.go` around lines 671 - 675, Fix the
NodeAttempt value in the lifecycle test by replacing the invalid new(now)
assignment for EndedAt with an addressable pointer to the existing now time.Time
variable, while leaving the other attempt fields unchanged.

@pedronauck

Copy link
Copy Markdown
Member Author

Superseded by the single final Loop node lifecycle PR now being assembled from the complete three-stack implementation. The remediation commit and review work from this PR are already included in the final branch.

This branch was successfully deployed

1 active deployment
Preview — 05fd58da Deployed Aug 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant