Skip to content

Prevent incomplete intents from reaching success and publication - #534

Merged
jeromevdl merged 5 commits into
mainfrom
fix/blocked-unit-completion
Oct 29, 2026
Merged

jeromevdl merged 5 commits into
mainfrom
fix/blocked-unit-completion

Conversation

@JWThewes

@JWThewes JWThewes commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

A dependent walking skeleton and blocked-only lanes could let an intent finish while required work remained. This change validates skeleton/wave selection, halts unresolved lanes, and uses one shared completion evaluator at section fan-in, final success, and initial publication.

The evaluator checks the resolved workflow's expected sections, current-plan units, persisted integration evidence and scoped approved omissions. Missing expected work, incomplete integrations and unavailable reads withhold success/publication and explain what remains. Required integration persistence failures stop dependent work; lost responses are reconciled against persisted evidence. Run/META/plan conditions fence finalization. Integrated labels distinguish code integration from final PR delivery.

Compatibility is explicit: pre-deploy MERGED rows without an integrationResults attribute remain integrated, including multi-repository retries and rewinds past completed sections. Every new-policy integration writes that attribute; explicit empty, invalid or failed evidence retains strict checks. PR-per-unit delivery still requires its authoritative repository records. Historical unpinned serial successes without unit work no longer show false unit-completion warnings. Cancellation and a valid current-run abort are classified before workflow-pin resolution; unfinished work remains visible and is not authorized for publication. Missing/truncated reads and unrelated or stale abort answers remain unverified.

The scope contains prevention and relevant review corrections. Existing orphan-lane repair and ordinary rewind remain, including omission reset, run-conflict 409 responses and exact intent-delete routing. Added preservation recovery for finished incomplete intents and its preview/lease/launch/compatibility protocols remain deferred with #536.

Original correction validation at e33d2cf on Node 24 with at most two backend workers and local Testcontainers:

  • Full backend: 5,302 passed, 3 existing Bun-dependent skips (254 passed files, 1 skipped).
  • Focused API/orchestrator/store/replay/real-Git checks: 529 passed. Actual rewind and FAILED/start entrypoints reach SUCCEEDED and publication without rebuilding pre-deploy integrated units.
  • Rendered IntentView/unit-lane tests: 91 passed. No frontend source change.
  • Lint, formatting, secretlint, dependency/SDK checks and docs build passed. Both incremental commits passed normal hooks: 1,052 tests each.
  • No package, Terraform or release-tooling change in this correction pass. No deployment, production audit/recovery or credentialed provider/model E2E was performed.

Cancellation amendment at 9288d89: focused shared/API/final-completion/frozen-replay checks 391 passed; normal commit hooks 1,059 passed. Lint, formatting, secretlint, dependency/SDK checks and docs build passed. The 5,302-test local full-suite result above is historical for the preceding head; it was not repeated locally for this small read-only classification change. Exact-head CI is reported separately by GitHub.

Deploy the orchestrator, runtime, API and frontend from the same release. New histories capture completion policy 1 in the existing load-meta boundary. Marker-absent histories retain policy 0's sequence, including frozen checkpoints before and after terminal success. The unreleased intermediate policy path is removed; available main/release history and branch workflow records contain no evidence of its deployment. No status migration or runtime repinning is introduced.

This PR targets main. #537 (delivery status/retry) and #539 (bounded read-only audit) are independent follow-ups based directly on this PR; neither requires #536 or the other follow-up. CI and review approval remain separate gates.

@jeromevdl jeromevdl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2 — A saved skeleton that a new DAG no longer supports now fails the run instead of falling back

Where: lambda/agentcore/commands/promote-units.js:136 (keeps the saved value), lambda/v2-orchestrator/index.js:1902-1915 (saves the auto-default), lambda/v2-orchestrator/section.js:384 (fallback removed), index.js:1908 and :1939 (the run fails).

What happens

  1. On the first promotion, effectiveSkeleton picks the first dependency-free unit and stores it in UNITPLAN. Fan-out approval writes the effective skeleton again, even when the human didn't override it (index.js:1902-1915). After that, an auto-default and a human choice look the same.
  2. A rewind to the unit-DAG stage or earlier regenerates the DAG. Units are often renamed, or their dependencies change.
  3. Re-promotion keeps the saved walkingSkeleton unchanged (promote-units.js:136). It only emits v2.units.decisions_invalid.
  4. defaultSkeletonFor (section.js:384) no longer replaces a slug that isn't in the plan. On the new path it returns the saved value.
  5. The fan-out gate shows the problem text, but the only way to fix it is typing a JSON walkingSkeleton override into the approve answer. GateCard has no picker.
  6. A plain approval reaches skeletonProblem (index.js:1908), then fail('invalid_walking_skeleton') (index.js:1939), and the intent becomes FAILED.

Before vs. after: before, a rewind with renamed units silently picked a valid dependency-free unit and continued. Now the same rewind followed by a plain approval fails the intent, which then needs the recovery flow.

Fix: keep a saved skeleton only while it is still a valid dependency-free unit in the new DAG; otherwise use the first candidate. Alternatively, ask again at the gate instead of failing. Add a test for re-promotion with renamed units.


P2 — Recovery is offered when the failure happened before the parallel section

Where: lambda/intents/lane-repair.js:12-44, frontend/src/pages/IntentView.tsx:373.

What happens

  1. A rewind to a shared stage before the section resets the unit rows to PENDING but keeps UNITPLAN (index.js:4972).
  2. That earlier stage then fails, and the intent becomes FAILED.
  3. unitCompletion reports incomplete, so:
    • the UI shows Incomplete work instead of FAILED;
    • the banner says "the recorded completion did not finish the current plan";
    • Preview recovery is offered.
  4. incompleteSectionRepair never checks whether the stages before the section completed. It plans a restart from the section's first stage.
  5. Confirm and continue retires the run, archives artifacts, resets stages and relaunches.
  6. The orchestrator's upstream check (v2-orchestrator/index.js:744-784) then fails immediately with rewind_upstream_incomplete.

Impact: the status shown is misleading, and a destructive recovery cycle runs only to fail.

Fix: in incompleteSectionRepair, require every stage before the section to be SUCCEEDED or SKIPPED (the same rule as the orchestrator's upstream check), and show Incomplete work only in that case.


P2 — SUCCEEDED intents can be re-run through the generic repair path

Where: lambda/intents/index.js:4148 (status check), index.js:4192 (prUnits), index.js:4622 (completionSkip: null).

What happens

  1. SUCCEEDED is now accepted for every repair type, not only incomplete-section recovery.

  2. For a complete intent, or one whose partial delivery was accepted, incompleteRepair is null, so the older PR-review path applies.

  3. prUnits filters on records.units without restricting to the current plan. A row from an earlier plan, or a human-skipped unit left in PR_DRAFT, can still qualify.

  4. The repair then:

    • moves the intent from SUCCEEDED back to CREATED;
    • clears completionSkip;
    • reruns the section.

    This path doesn't require a preview token.

  5. The UI never shows the button in this state. An owner or admin calling the API directly can still trigger it.

Impact: a delivered intent can be reopened, and a recorded human skip undone. That contradicts the docs: accepted partial delivery must not be automatically undone.

Fix: accept SUCCEEDED only when incompleteRepair is non-null.


P2 — The final-PR safety check passes when the graph record is missing

Where: lambda/intents/index.js:4303. The PR record comes from v2-orchestrator/index.js:2159-2200.

What happens

  1. Recovery should stop if the final intent PR is closed or merged.
  2. It looks for that PR only in Neptune HAS_PR vertices (fetchPullRequests).
  3. Those vertices are written by record-pr, which is explicitly best-effort: a failure "never un-succeeds a run".
  4. If that write failed, finalPrs is empty, so recovery proceeds without checking the provider. The preview then says "Existing unit pull requests are reused".
  5. A merged or closed final PR goes undetected.

Fix: if delivery already ran (the delivery stage row is SUCCEEDED, or a PR event exists) and no PR record is found, refuse recovery. Or look up the PR at the provider by intent branch.

Comment thread lambda/intents/index.js Outdated
startedAt: meta.startedAt,
pendingHumanTaskId: null,
resumeRequired: null,
failureReason: 'lane_repair_in_progress',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P1 — An interrupted repair locks the intent permanently

lambda/intents/index.js:4447 sets the lock; the checks at :4153 and :1790-1802 refuse everything while it is set.

What happens:
the retirement write leaves the intent FAILED with failureReason: 'lane_repair_in_progress'. Only the success path or the JS catch clears it. If the Lambda times out (120s), runs out of memory or is killed mid-repair, the reason stays, and nothing makes it expire. After that:
repair and preview return 409 repair_in_progress;
rewind, recompose, cancel and DELETE return 409 from the new guard;
the only way out is POST /start. It accepts FAILED, isn't guarded and resets failureReason. But it relaunches without startAtStageId, so the reviewed recovery's guarantees are lost: no restart from the section boundary and no skeleton reselection. The UI's recovery button only calls /start when no stage row is FAILED; otherwise it calls rewind, which is blocked.

Why it's plausible: repair makes many serial calls for every lane: provider PR checks, StopDurableExecution, Neptune artifact archiving and session stops.

Before this PR: the same intermediate state could still be repaired or rewound. Now nothing in the product clears it; only a manual DynamoDB patch, which the docs forbid.

Tests: only the lane_repair_failed case is covered (
intents.test.js:6618). The docs' claim that "interrupted cleanup leaves recoverable FAILED state" holds only for thrown errors.

Fix: make the lock a lease. Store a repairStartedAt and treat an old lane_repair_in_progress as lane_repair_failed, so preview and recovery can take over. Add a test that leaves the lock set without the catch running. Optionally, guard /start the same way so recovery and restart can't race.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jeromevdl Fixed in cad1d0e. Repair now has a five-minute lease and retains the reviewed section, lane set and skeleton choice across a worker crash. An expired lease permits a fresh preview and conditional takeover; generic start cannot bypass it. Late-owner writes and cleanup are fenced, and the UI exposes interrupted recovery even for a stranded CREATED launch.

Validation: 324 targeted API/DynamoDB Local tests and 68 intent-view tests passed. The regression suspends the first worker without its catch running, expires its lease, recovers once, then resumes the old worker and verifies it cannot alter the successor. Normal hooks passed 1,438 tests. Final combined CI will follow the remaining review fixes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jeromevdl Follow-up for the interrupted-repair finding: f2a88f3 preserves the reviewed recovery lease through the launch handoff. A successful Invoke acknowledgement no longer lets /start race the new execution while META is still CREATED. The guard retires when the orchestrator claims its new run; if that claim never happens, the expired lease offers the same reviewed recovery preview.

Validation: 350 intent API tests passed, including the CREATED handoff, blocked /start, expired-preview recovery, and successful new-run claim; normal commit hooks passed 363 tests. Combined final validation/CI is being run separately.

Comment thread lambda/v2-orchestrator/section.js Outdated
sectionIndex: segment.index,
prompt: [
`Lane failure in section ${segment.index} (${failedSlugs.length} unit(s) failed, ${blocked().length} blocked, ${
`Unresolved work in section ${segment.index} (${failedSlugs.length} unit(s) need a decision, ${blocked().length} blocked, ${

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor: the halt prompt counts units twice. failedSlugs now includes blocked units, and blocked units are counted again separately.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jeromevdl Fixed in 077bb63. The halt prompt now counts failed and blocked units as disjoint categories and labels integrated work separately; explicitly omitted lanes are excluded from unresolved counts.

Validation: 122 orchestrator tests passed, including rendered gate prompts for blocked-only work (0 failed, 1 blocked) and a failed prerequisite with its blocked dependent (1 failed, 1 blocked). Both still stop shared work without a recorded outcome. Normal hooks passed 582 tests.

Comment thread lambda/intents/index.js Outdated
resumeRequired: null,
failureReason: 'rewind_in_progress',
completedAt: null,
ifOrchestratorRunId: meta.orchestratorRunId,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

the new ifOrchestratorRunId: meta.orchestratorRunId condition throws an uncaught ConditionalCheckFailedException when it loses a race. The caller gets a 500; a 409 would be clearer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jeromevdl Fixed the rewind caller in 764bcf3. An ownership condition lost during retirement now returns 409 execution_changed with a refresh/review instruction. Unexpected storage errors remain errors.

Validation: 324 intents tests passed. The regression changes the run owner after archival but before the retirement write, then checks that the pending gate, completed stages and integrated unit remain intact and no session stop or orchestrator launch occurs. A separate storage-failure case still returns 500. Normal hooks passed.

Comment thread lambda/intents/index.js Outdated
await store.updateExecution({
executionId: intentId,
fromStatus: meta.status,
ifOrchestratorRunId: meta.orchestratorRunId,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

the new ifOrchestratorRunId: meta.orchestratorRunId condition throws an uncaught ConditionalCheckFailedException when it loses a race. The caller gets a 500; a 409 would be clearer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jeromevdl Fixed the separate recompose caller in 9a5be53. A lost retirement condition now returns 409 execution_changed and asks the caller to refresh the stage selection. Unexpected storage failures remain errors.

Validation: 326 intents tests passed, including an owner change between archival and retirement. The regression verifies that the pending gate and stage remain unchanged, the grid is not replaced, and no session stop or relaunch happens. The storage-failure control still returns 500. Normal hooks passed.

Comment thread lambda/intents/index.js Outdated
Comment on lines +1790 to +1802
if (
intentId &&
(httpMethod === 'DELETE' ||
(httpMethod === 'POST' && /\/(rewind|recompose|cancel)$/.test(path ?? '')))
) {
const current = await store.getExecution(intentId, { consistentRead: true });
if (current?.projectId === projectId && current.failureReason === 'lane_repair_in_progress') {
return response(409, {
error: 'Recovery is in progress. Refresh before changing this execution.',
code: 'repair_in_progress',
});
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

the early repair guard matches any DELETE with an intentId (httpMethod === 'DELETE', line 1792), not only intent deletion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jeromevdl Fixed in 0ceb792. The early guard now matches the intent DELETE route and the named execution actions only. The actual intent-delete dispatcher also excludes unknown nested DELETE paths.

Validation: 327 intents tests passed. During a repair, root deletion still returns repair_in_progress; attachment deletion reaches its own DRAFT-only validation, and an unknown nested DELETE cannot delete the intent. Normal hooks passed.

Comment thread lambda/intents/lane-repair.js Outdated
Comment on lines +70 to +82
export const repairPreviewToken = (meta, records, preview) =>
createHash('sha256')
.update(
JSON.stringify({
run: meta.orchestratorRunId ?? null,
status: meta.status,
updatedAt: meta.updatedAt,
plan: records.unitPlan,
units: records.units,
stages: records.stages,
preview,
}),
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(repairPreviewToken, hashes meta.updatedAt at line 76 and records.stages at line 79), checked at lambda/intents/index.js:4396 and :4401: for a RUNNING orphaned-lane repair, sibling lanes that are still active can keep changing these and invalidating the preview.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jeromevdl Fixed in b6bcf11. Orphaned-lane preview tokens now ignore output timestamps, routine sibling stage progress and equivalent active states. They still bind the run, plan, candidate integration/skip decisions, and orphaned gate attempt/callback identity. The API re-reads META immediately before claiming retirement after provider checks.

Validation: 333 intents tests passed. A preview remains usable while a sibling finishes a stage and provider reads advance META; its completed stage is preserved. Separate run, DAG, integrated-candidate, autonomy and new-question changes all reject the old preview before cleanup. Normal hooks passed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jeromevdl Follow-up for preview stability: a24a991 re-reads the relevant execution records after provider checks, before the conditional claim. Integration, decision or gate changes during those reads return 409 before cleanup; a sibling stage that finishes during the reads remains SUCCEEDED without a reset or attempt increment.

All four new API regressions failed before this correction and now pass. Validation: 354 intent API tests and 367 tests in normal commit hooks passed. Combined final validation/CI will be reported separately.

@JWThewes

Copy link
Copy Markdown
Contributor Author

@jeromevdl Addressed “A saved skeleton that a new DAG no longer supports now fails the run instead of falling back” from your review in 5e9ec41. Re-promotion now preserves valid choices and selects a dependency-free default when the regenerated DAG invalidates the old selection. It records that change before the normal fan-out approval; original answers remain in history.

Validation: 143 promotion/orchestrator tests passed, including the actual promotion command followed by plain approval for renamed/dependency-changed skeletons, the ceremony-disabled control, valid saved overrides and legacy absent/empty selections. Invalid choices in an already approved plan still cannot execute. Normal hooks passed.

@JWThewes

Copy link
Copy Markdown
Contributor Author

@jeromevdl Addressed “Recovery is offered when the failure happened before the parallel section” from your review in cebc3b4. Preview/admission now require every upstream stage instance to be SUCCEEDED or SKIPPED, matching the orchestrator. The detail API reports eligibility, so an earlier failure stays FAILED and retains its existing stage retry instead of offering destructive section recovery.

Validation: 343 backend tests and 69 intent-view tests passed. Coverage includes missing/FAILED/PENDING/RUNNING upstream rows, SUCCEEDED/SKIPPED controls, earlier parallel instances, zero cleanup on rejection, and clicking the correct upstream retry action. Normal hooks passed.

@JWThewes

Copy link
Copy Markdown
Contributor Author

@jeromevdl Addressed “SUCCEEDED intents can be re-run through the generic repair path” from your review in 0c7bd2a. SUCCEEDED now requires an actual incomplete-section repair; generic orphan/PR-review repair cannot reopen completed or accepted partial delivery.

Validation: 343 intents tests passed, including historical PR_DRAFT rows outside the plan, plan-bound skip receipts, legacy accepted halt/skip answers, and genuine incomplete recovery that leaves another unit’s skip intact. Rejected preview and direct API requests make no writes. Normal hooks passed.

@JWThewes

Copy link
Copy Markdown
Contributor Author

@jeromevdl Addressed “The final-PR safety check passes when the graph record is missing” from your review in 844b029. Recovery now refuses repair_delivery_unverified when completion/delivery evidence exists but the final PR projection is missing. It does not infer that no final PR exists, and the preview no longer claims unit PR reuse from an empty list.

Validation: 353 backend tests and 69 intent-view tests passed. Tests remove the graph vertex and cover success, PR-open events, completed/skipped final shared work and tracker receipts; preview and direct recovery make no writes. Provider failures remain 502, closed/merged PRs remain blocked, and verified open-final-PR and pre-delivery recovery remain supported. Normal hooks passed.

@JWThewes JWThewes changed the title Prevent blocked units from completing intents and add reviewed recovery Prevent incomplete intents from reaching success and publication Oct 15, 2026
@JWThewes
JWThewes removed this pull request from stack #551 October 15, 2026 15:36
@JWThewes

Copy link
Copy Markdown
Contributor Author

@jeromevdl I've reworked this following your scope and completion-ownership feedback. The restructuring is in 324cb24; current head is a870e6b.

Validation: 5,287 backend tests passed (3 existing skips), 836 frontend tests and production build, 97 release tests, plus static checks and replay/ordinary-recovery regressions. All seven current-head CI checks pass. The two follow-ups were also merged locally without conflicts and passed their combined checks. No deployment was performed as part of this restructuring.

Could you re-review the narrowed scope and shared completion ownership?

@jeromevdl jeromevdl left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The restructure resolves most of my first review: the repair-recovery findings no longer apply, and the skeleton finding and the optional nits are fixed. Three new issues come from the completion check that is now part of the PR.

P1 — Units integrated before deploy in multi-repo intents block any later run

Where: lambda/shared/unit-completion.js:128-150 (integrationProblem, the repos.length > 1 branch at :147), lambda/v2-orchestrator/section.js:541-556.

What happens

  1. Before this PR, the merge step stored only mergedAt. It now also stores per-repository integrationResults.
  2. For multi-repo intents that don't use one PR per unit, a MERGED row without integrationResults is treated as repository_evidence_missing, i.e. unknown.
  3. A new history on a pre-deploy intent hits this. A new history comes from:
    • rewind to a stage after the section;
    • /start retry of a FAILED intent;
    • recompose.
  4. The result depends on where the run picks up:
    • rewind past the section: everything runs, then the final step marks the intent FAILED with completion_unverified and doesn't open the PR;
    • /start retry: the section-start check (section.js:555) fails immediately.
  5. The only way out is rewinding into the section, which resets and rebuilds work that was already integrated.

Before vs. after: before, these retries completed normally. Single-repo and one-PR-per-unit intents are not affected.

Fix: treat a MERGED row with no integrationResults attribute as a pre-deploy row and accept it as integrated, the same way policy 0 did. Every policy-2 merge writes the field, even as [], so a missing attribute reliably identifies an old row. Keep the strict per-repository check for rows that have the field. Please add a regression test: a multi-repo intent with pre-deploy MERGED rows, then rewind past the section and /start retry, both reaching SUCCEEDED.


P2 — Old successful intents without a release pin show "Completion unverified", even serial ones

Where:
lambda/shared/unit-completion.js:331, lambda/intents/index.js:5254 and frontend/src/pages/IntentView.tsx:346, :425, :596

What happens:

  1. GET on a terminal intent calls assessExecutionCompletion.
  2. That function returns unknown (legacy_workflow_unpinned) whenever there is no methodologyRelease, no methodologyPins.STAGE and no saved completion result. This check runs before it looks at whether the intent has a unit plan at all.
  3. Every pre-deploy SUCCEEDED intent created without a release pin is affected, including serial workflows with no units. That is a supported creation path: selectedReleasePin can be null, index.js:5464 and :5598.
  4. Those intents get the badge Completion unverified, the banner "Completion could not be verified… before publishing", and "0 of 0 units verified integrated".
  5. No test covers an unpinned intent without units.

Fix: return null (or complete) when records.unitPlan?.units is empty, before the pin check. Add a GET test for a serial, unpinned SUCCEEDED intent.
Also move the CANCELLED/abort check ahead of the legacy_workflow_unpinned return: an unpinned CANCELLED intent with units is currently reported as unknown instead of aborted. #534's UI doesn't show this, but #539's /audit card and audit-completion --status CANCELLED do (counted as unknown, exit 2).


P2 (maintainability) — Policy 1 was never released; collapse to a single new version

completionPolicyVersion does not exist on main (git log -S completionPolicyVersion origin/main is empty), and the deploy workflows have never run from this branch. Policy 1 and policy 2 are both introduced by this PR, so the only histories in the field are marker-absent (policy 0).

The >= 1 / >= 2 split therefore keeps an unreachable third path: the frozen policy-1 branch in validateCompletion, checkedCompletion vs completionPolicyVersion >= 2 checks in section.js/index.js, test/fixtures/completion-v1-boundary.js and its replay tests. Every future change to the section runner would have to preserve it.

Unless policy 1 was deployed manually somewhere with live histories, please make the current behaviour version 1, keep the policy-0 legacy path, and drop the intermediate one.

A dependent walking skeleton or blocked-only lanes could let an intent
finish while required current-plan work remained. Validate skeleton and
wave selection, halt on unresolved lanes, and use one shared completion
evaluator (lambda/shared/unit-completion.js) at section fan-in, final
success and initial publication for new histories.

Required integration writes are no longer best-effort: an unrecorded
integration stops the lane, a lost response is reconciled from persisted
state, and final status commits with its completion receipt under run,
status, META revision and unit-plan conditions. Marker-absent histories
keep their durable operation sequence.

Review corrections included: re-promotion reselects a valid skeleton for a
regenerated DAG, halt prompts count failed and blocked lanes separately,
rewind/recompose return 409 execution_changed when retirement loses run
ownership, and intent DELETE matches only the intent route.

Squashes the reviewed restructure (originally 8018321..a870e6b) onto
main; the deferred preservation-recovery work is not included.
@JWThewes
JWThewes force-pushed the fix/blocked-unit-completion branch from 9288d89 to b6883ab Compare October 26, 2026 13:06
@JWThewes

Copy link
Copy Markdown
Contributor Author

@jeromevdl Addressed Units integrated before deploy in multi-repo intents block any later run from your review in e6f64ed. A MERGED row with no integrationResults attribute is accepted as a pre-deploy integration again. Rows that carry the attribute (including [], which every new integration writes) keep the strict per-repository check. A new intents regression runs a multi-repo intent with pre-deploy MERGED rows through both a rewind past the section and a /start retry: both reach SUCCEEDED and open both PRs, with no lane rerun or merge.

@JWThewes

Copy link
Copy Markdown
Contributor Author

@jeromevdl Addressed Old successful intents without a release pin show "Completion unverified", even serial ones from your review in e6f64ed and b6883ab. An unpinned intent with no unit plan and no unit rows now gets unitCompletion: null (GET test for a serial, unpinned SUCCEEDED intent). CANCELLED intents and current-run recorded aborts are now classified as aborted before the legacy_workflow_unpinned check, without loading the workflow.

@JWThewes

Copy link
Copy Markdown
Contributor Author

@jeromevdl Addressed Policy 1 was never released; collapse to a single new version from your review in 3190291. The current behaviour is now completion policy 1 above the marker-absent policy-0 path. The intermediate branch in validateCompletion, the separate >= 2 checks and test/fixtures/completion-v1-boundary.js with its replay tests are gone.

@jeromevdl

jeromevdl commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

@JWThewes, a new blocker is introduced (same issue as #569), otherwise the rest is fixed.

P0 — The orchestrator role lacks dynamodb:ConditionCheckItem; every new intent fails after deploy

Where: terraform/modules/api/lambda/main.tf:3372 (not changed by this PR).

What happens

  1. The v2-orchestrator role allows only GetItem, PutItem, UpdateItem and Query on the process table.
  2. This PR adds the first orchestrator-issued transactions with ConditionCheck entries, which DynamoDB authorizes as dynamodb:ConditionCheckItem:
    • updateForRun (integration write, skip receipts, updateUnitPlanDecisions with ifOrchestratorRunId at fan-out approval, index.js:2217);
    • finalizeUnitCompletion with ifUnitPlan: records.unitPlan (unit-completion.js:290).
  3. ifUnitPlan is null (not undefined) for serial intents, so the ConditionCheck is sent for every intent.
  4. Result: AccessDeniedException. It is not a conditional failure, so after the re-read finds no receipt the call throws, the orchestrator calls fail('completion_unverified'), the intent ends FAILED and no PR is opened. This applies to every new history; parallel intents already fail at fan-out approval.
  5. Tests pass because DynamoDB Local does not enforce IAM.

Precedent: #569 (264754b3) fixed the same issue for the intents role ("every answer to an engine gate failed with AccessDeniedException").

Fix: add "dynamodb:ConditionCheckItem" to the v2-orchestrator process-table statement. The AgentCore and intents roles already have it.

The completion work adds the first orchestrator-issued transactions with
ConditionCheck entries: updateForRun (integration writes, skip receipts,
fan-out unit-plan decisions), deleteWorkflowCheckpoint with a run fence, and
finalizeUnitCompletion with its unit-plan snapshot check. DynamoDB authorizes
each ConditionCheck as dynamodb:ConditionCheckItem, which the orchestrator role
never had. The call fails with AccessDeniedException rather than a conditional
failure, so the orchestrator ends the intent FAILED with completion_unverified.

DynamoDB Local does not enforce IAM, so the behavior tests cannot see this. Add
a policy test, as the AgentCore role already has, and grant the action on the
process table statement. Same class of failure as #569 for the intents role.
@JWThewes

Copy link
Copy Markdown
Contributor Author

@jeromevdl Addressed The orchestrator role lacks dynamodb:ConditionCheckItem; every new intent fails after deploy from your comment in 76a54fd. The v2-orchestrator process-table statement in terraform/modules/api/lambda/main.tf now allows dynamodb:ConditionCheckItem, with a comment saying why. The intents role already had it; the projects, trackers and intent-pin-lookup roles don't reach the new transaction paths.

A new lambda/v2-orchestrator/test/orchestrator-iam-policy.test.js reads the Terraform and checks that statement, the same way the AgentCore role's test does. It fails without the grant and passes with it. That covers the gap you named: DynamoDB Local doesn't enforce IAM, so the transaction tests could not catch it. terraform fmt -check passes. I did not rerun the full backend suite, since this commit changes only an IAM statement and adds that test.

@jeromevdl jeromevdl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

All good now, thanks

@jeromevdl
jeromevdl merged commit 8451788 into main Oct 29, 2026
10 of 16 checks passed
@jeromevdl jeromevdl added area/frontend React application in frontend/ area/agents AgentCore runtime: CLI drivers, MCP tools, workspace and Git operations area/backend Lambda APIs, lambda/shared, v2-orchestrator area/infra Terraform, installer and deployment size/XL 3000+ changed lines (lockfiles excluded), consider splitting labels Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/agents AgentCore runtime: CLI drivers, MCP tools, workspace and Git operations area/backend Lambda APIs, lambda/shared, v2-orchestrator area/frontend React application in frontend/ area/infra Terraform, installer and deployment size/XL 3000+ changed lines (lockfiles excluded), consider splitting

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants