Skip to content

fix(run-engine,webapp): guard run finalization against lost resume signals - #4

Open
anurag6569201 wants to merge 1 commit into
qa/agent-triggerdotdev-trigger-dev/pr-06-4849/basefrom
qa/agent-triggerdotdev-trigger-dev/pr-06-4849/head
Open

fix(run-engine,webapp): guard run finalization against lost resume signals#4
anurag6569201 wants to merge 1 commit into
qa/agent-triggerdotdev-trigger-dev/pr-06-4849/basefrom
qa/agent-triggerdotdev-trigger-dev/pr-06-4849/head

Conversation

@anurag6569201

Copy link
Copy Markdown

Summary

A run's finish commit and its follow-up side effects (completing the associated waitpoint, waking blocked parents, releasing the queue slot, nudging batch completion) are separate writes across Postgres and Redis. If a database error landed between them, the child run was already finished, so the runner's retries hit the "Run is already finished" guard and the completion signal was lost for good. A parent blocked on triggerAndWait or batchTriggerAndWait then stayed waiting forever. TTL expiry had the same shape: its worker retry returned early on a non-pending run, and the batch expiry path swallowed a failed waitpoint-job enqueue.

Fix

Every finalizing path (attempt success, permanent failure, cancellation, TTL expiry) now enqueues a durable ensureRunFinalized job before the finish commit, and acks it once the inline side effects all succeed. In steady state the guard never executes; the cost is one Redis enqueue and ack per completion.

When the inline path dies in between, the guard fires after a short delay and re-derives everything from current state: it releases the run's queue message and concurrency slot, completes a still-pending associated waitpoint from the run row's output or error, re-runs the blocked-run fan-out (covering a lost unblock enqueue even after the waitpoint committed), and re-schedules the batch completion check. Every leg is idempotent, so racing the inline path is a no-op. The job retries with a capped backoff for roughly five weeks before dead-lettering, so it outlives any database outage while a genuinely poisoned item still becomes visible.

Cancellation gets special handling: CANCELED is the only terminal run status where execution can still be in flight, so the guard only re-delivers for a canceled run once its execution snapshot is FINISHED, re-arming itself until then rather than resuming the parent while the child is still winding down.

A finalization_rederivations counter increments whenever the guard actually re-delivers a lost signal; it should stay at zero in a healthy system.

Tests cover six shapes: waitpoint completion lost after the finish commit, unblock fan-out lost after the waitpoint completed, a failed guard enqueue failing the completion request with nothing committed, a stale guard held back during an in-flight cancellation, waitpoint completion lost during TTL expiry, and the happy path where the guard is acked and never runs.

Known accepted edge: a guard re-run after a partial inline completion can re-emit a cached-run completion event for the same span; this only happens during failure recovery and is bounded to duplicate trace events.

Source merge-base: 1d55693c0fc76279e7e8275fe41f959b65d5ea99
Source head: 933a6a64404f7b9d22a0e5d9e1f133bb304d38e8

@shipwright-agent

Copy link
Copy Markdown

⛔ Shipwright · Blocked

Recommendation: do not merge PR #4 · Tier T3
Checks: 0 total · 0 needing attention

Next step: resolve the blocking findings before merge.

Findings (7)

  • CRITICAL The batch TTL path acks the finalization guard for runs without an associatedWaitpoint immediately after enqueueing finishWaitpoint, but the comment claims the guard stays armed fo · internal-packages/run-engine/src/engine/systems/ttlSystem.ts:291
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • CRITICAL In the single-run TTL path, the guard is scheduled before expireRun commits, but the ack only runs after expireRun succeeds. · internal-packages/run-engine/src/engine/systems/ttlSystem.ts:83
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • HIGH In the batch path, the guard is acked for runs without an associatedWaitpoint immediately after enqueueing the finishWaitpoint job. · internal-packages/run-engine/src/engine/systems/ttlSystem.ts:291
    • Fix: Fix the review finding before release.
  • HIGH The finalization guard delay is duplicated as a magic default of 60_000 in both RunAttemptSystem and TtlSystem constructors, with no shared constant. · internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts:138
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • HIGH The ensureRunFinalized method is public and directly callable from tests and other systems, but its cancellation deferral logic depends on deferCount being passed correctly. · internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts:1840
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • HIGH The ensureRunFinalized worker job accepts a runId and deferCount with no authentication or authorization boundary. · internal-packages/run-engine/src/engine/workerCatalog.ts:90
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • MEDIUM The finalization guard is scheduled before the run is expired, but the ack in the single-run path is only executed inside the transaction callback after expireRun succeeds. · internal-packages/run-engine/src/engine/systems/ttlSystem.ts:83
    • Fix: Fix the review finding before release.

Fireworks usage: 30,893 input · 925 output · 31,818 total tokens · $0.0074 · 17s · 0 fix iteration(s)

Open the Shipwright check for full evidence and the audit bundle. Use /shipwright rerun to verify again.

environment: { id: run.runtimeEnvironmentId },
});

/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · CRITICAL

The batch TTL path acks the finalization guard for runs without an associatedWaitpoint immediately after enqueueing finishWaitpoint, but the comment claims the guard stays armed fo

Impact: The batch TTL path acks the finalization guard for runs without an associatedWaitpoint immediately after enqueueing finishWaitpoint, but the comment claims the guard stays armed for runs with a waiting parent. The condition checks associatedWaitpoint, not whether a parent is actually waiting. A run with a waiting parent but no associatedWaitpoint will have its guard released prematurely, re-introducing the exact str…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

raw: `Run expired because the TTL (${run.ttl}) was reached`,
};

await this.#scheduleFinalizationGuard(runId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · CRITICAL

In the single-run TTL path, the guard is scheduled before expireRun commits, but the ack only runs after expireRun succeeds.

Impact: In the single-run TTL path, the guard is scheduled before expireRun commits, but the ack only runs after expireRun succeeds. If expireRun throws, the guard remains armed and later fires ensureRunFinalized for a run that was never expired. ensureRunFinalized only checks isFinalRunStatus, so a non-final run returns early, but the stale guard can still race a later legitimate finalization and re-deliver side effects fo…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

environment: { id: run.runtimeEnvironmentId },
});

/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · HIGH

In the batch path, the guard is acked for runs without an associatedWaitpoint immediately after enqueueing the finishWaitpoint job.

Impact: In the batch path, the guard is acked for runs without an associatedWaitpoint immediately after enqueueing the finishWaitpoint job. However, the comment says the guard stays armed for runs with a waiting parent and verifies the completion landed. The condition checks associatedWaitpoint, not whether the run has a waiting parent, so a run with an associatedWaitpoint that is not actually waiting on a parent will keep…

Suggested fix: Fix the review finding before release.

this.batchSystem = options.batchSystem;
this.waitpointSystem = options.waitpointSystem;
this.delayedRunSystem = options.delayedRunSystem;
this.finalizationGuardDelayMs = options.finalizationGuardDelayMs ?? 60_000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · HIGH

The finalization guard delay is duplicated as a magic default of 60_000 in both RunAttemptSystem and TtlSystem constructors, with no shared constant.

Impact: The finalization guard delay is duplicated as a magic default of 60_000 in both RunAttemptSystem and TtlSystem constructors, with no shared constant. A future change to one default will silently diverge the two systems and produce inconsistent guard timing.

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

return startSpan(this.$.tracer, "ensureRunFinalized", async (span) => {
span.setAttribute("runId", runId);

const run = await this.$.runStore.findRun(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · HIGH

The ensureRunFinalized method is public and directly callable from tests and other systems, but its cancellation deferral logic depends on deferCount being passed correctly.

Impact: The ensureRunFinalized method is public and directly callable from tests and other systems, but its cancellation deferral logic depends on deferCount being passed correctly. A caller that omits deferCount resets the budget to zero on every invocation, allowing an unbounded number of deferrals if the worker never reaches a finished execution state.

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

* silently forever.
*/
ensureRunFinalized: {
schema: z.object({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · HIGH

The ensureRunFinalized worker job accepts a runId and deferCount with no authentication or authorization boundary.

Impact: The ensureRunFinalized worker job accepts a runId and deferCount with no authentication or authorization boundary. Any caller that can enqueue to the worker catalog can force re-delivery of finalization side effects for arbitrary run IDs, including queue acks, waitpoint completion, and batch scheduling. This is a new internal attack surface that should be restricted to the run attempt and TTL systems.

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

raw: `Run expired because the TTL (${run.ttl}) was reached`,
};

await this.#scheduleFinalizationGuard(runId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · MEDIUM

The finalization guard is scheduled before the run is expired, but the ack in the single-run path is only executed inside the transaction callback after expireRun succeeds.

Impact: The finalization guard is scheduled before the run is expired, but the ack in the single-run path is only executed inside the transaction callback after expireRun succeeds. If expireRun throws, the guard remains armed and will later fire ensureRunFinalized for a run that was never expired, potentially causing incorrect finalization side effects.

Suggested fix: Fix the review finding before release.

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